Initial commit: Triple-C app, container, and CI
Tauri v2 desktop app (React/TypeScript + Rust) for managing containerized Claude Code environments. Includes Gitea Actions workflow for building and pushing the sandbox container image, and a BUILDING.md guide for manual app builds on Linux and Windows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
66
app/src/App.tsx
Normal file
66
app/src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import StatusBar from "./components/layout/StatusBar";
|
||||
import TerminalView from "./components/terminal/TerminalView";
|
||||
import { useDocker } from "./hooks/useDocker";
|
||||
import { useSettings } from "./hooks/useSettings";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useAppState } from "./store/appState";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage } = useDocker();
|
||||
const { checkApiKey } = useSettings();
|
||||
const { refresh } = useProjects();
|
||||
const { sessions, activeSessionId } = useAppState();
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
checkDocker();
|
||||
checkImage();
|
||||
checkApiKey();
|
||||
refresh();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-6 gap-4 bg-[var(--bg-primary)]">
|
||||
<TopBar />
|
||||
<div className="flex flex-1 min-h-0 gap-4">
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
|
||||
{sessions.length === 0 ? (
|
||||
<WelcomeScreen />
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
{sessions.map((session) => (
|
||||
<TerminalView
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeScreen() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
|
||||
Triple-C
|
||||
</h1>
|
||||
<p className="text-sm mb-4">Claude Code Container</p>
|
||||
<p className="text-xs max-w-md">
|
||||
Add a project from the sidebar, start its container, then open a
|
||||
terminal to begin using Claude Code in a sandboxed environment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
app/src/components/containers/SiblingContainers.tsx
Normal file
67
app/src/components/containers/SiblingContainers.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { listSiblingContainers } from "../../lib/tauri-commands";
|
||||
import type { SiblingContainer } from "../../lib/types";
|
||||
|
||||
export default function SiblingContainers() {
|
||||
const [containers, setContainers] = useState<SiblingContainer[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listSiblingContainers();
|
||||
setContainers(list);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">Sibling Containers</h3>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
{containers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No other containers found.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{containers.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
c.state === "running"
|
||||
? "bg-[var(--success)]"
|
||||
: "bg-[var(--text-secondary)]"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium truncate">
|
||||
{c.names?.[0]?.replace(/^\//, "") ?? c.id.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[var(--text-secondary)] mt-0.5 ml-4">
|
||||
{c.image} — {c.status}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/src/components/layout/Sidebar.tsx
Normal file
40
app/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAppState } from "../../store/appState";
|
||||
import ProjectList from "../projects/ProjectList";
|
||||
import SettingsPanel from "../settings/SettingsPanel";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { sidebarView, setSidebarView } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-64 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
{/* Nav tabs */}
|
||||
<div className="flex border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onClick={() => setSidebarView("projects")}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||
sidebarView === "projects"
|
||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSidebarView("settings")}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||
sidebarView === "settings"
|
||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/src/components/layout/StatusBar.tsx
Normal file
22
app/src/components/layout/StatusBar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
export default function StatusBar() {
|
||||
const { projects, sessions } = useAppState();
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-6 px-3 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
||||
<span>
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>
|
||||
{running} running
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>
|
||||
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/src/components/layout/TopBar.tsx
Normal file
31
app/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import TerminalTabs from "../terminal/TerminalTabs";
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
export default function TopBar() {
|
||||
const { dockerAvailable, imageExists } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<TerminalTabs />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]">
|
||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||
<StatusDot ok={imageExists === true} label="Image" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
|
||||
}`}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
99
app/src/components/projects/AddProjectDialog.tsx
Normal file
99
app/src/components/projects/AddProjectDialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddProjectDialog({ onClose }: Props) {
|
||||
const { add } = useProjects();
|
||||
const [name, setName] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
setPath(selected);
|
||||
if (!name) {
|
||||
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
|
||||
setName(parts[parts.length - 1]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !path.trim()) {
|
||||
setError("Name and path are required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await add(name.trim(), path.trim());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
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)]"
|
||||
/>
|
||||
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Path
|
||||
</label>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Project"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
app/src/components/projects/ProjectCard.tsx
Normal file
291
app/src/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project, AuthMode } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export default function ProjectCard({ project }: Props) {
|
||||
const { selectedProjectId, setSelectedProject } = useAppState();
|
||||
const { start, stop, rebuild, remove, update } = useProjects();
|
||||
const { open: openTerminal } = useTerminal();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
const handleStart = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await start(project.id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await stop(project.id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTerminal = async () => {
|
||||
try {
|
||||
await openTerminal(project.id, project.name);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthModeChange = async (mode: AuthMode) => {
|
||||
try {
|
||||
await update({ ...project, auth_mode: mode });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSSH = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
try {
|
||||
await update({ ...project, ssh_key_path: selected as string });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = {
|
||||
stopped: "bg-[var(--text-secondary)]",
|
||||
starting: "bg-[var(--warning)]",
|
||||
running: "bg-[var(--success)]",
|
||||
stopping: "bg-[var(--warning)]",
|
||||
error: "bg-[var(--error)]",
|
||||
}[project.status];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setSelectedProject(project.id)}
|
||||
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
|
||||
{project.path}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-4 space-y-2">
|
||||
{/* Auth mode selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "login"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
/login
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "api_key"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
API key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{isStopped ? (
|
||||
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
||||
) : project.status === "running" ? (
|
||||
<>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
label="Reset"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{project.status}...
|
||||
</span>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
||||
disabled={false}
|
||||
label={showConfig ? "Hide" : "Config"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (confirm(`Remove project "${project.name}"?`)) {
|
||||
await remove(project.id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config panel */}
|
||||
{showConfig && (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
||||
{/* SSH Key */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={project.ssh_key_path ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowseSSH}
|
||||
disabled={!isStopped}
|
||||
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||
<input
|
||||
value={project.git_user_name ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Git Email */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||
<input
|
||||
value={project.git_user_email ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Git Token (HTTPS) */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={project.git_token ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Docker access toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||
project.allow_docker_access
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{project.allow_docker_access ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
onClick,
|
||||
disabled,
|
||||
label,
|
||||
accent,
|
||||
danger,
|
||||
}: {
|
||||
onClick: (e?: React.MouseEvent) => void;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
accent?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
||||
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
||||
if (danger) color = "text-[var(--error)] hover:text-[var(--error)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||
disabled={disabled}
|
||||
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
40
app/src/components/projects/ProjectList.tsx
Normal file
40
app/src/components/projects/ProjectList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import ProjectCard from "./ProjectCard";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
|
||||
export default function ProjectList() {
|
||||
const { projects } = useProjects();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||
Projects
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
|
||||
title="Add project"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<p className="px-2 text-sm text-[var(--text-secondary)]">
|
||||
No projects yet. Click + to add one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
app/src/components/settings/ApiKeyInput.tsx
Normal file
68
app/src/components/settings/ApiKeyInput.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
export default function ApiKeyInput() {
|
||||
const { hasKey, saveApiKey, removeApiKey } = useSettings();
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!key.trim()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveApiKey(key.trim());
|
||||
setKey("");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Each project can use either <strong>claude login</strong> (OAuth, run inside the terminal) or an <strong>API key</strong>. Set auth mode per-project.
|
||||
</p>
|
||||
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
||||
API Key (for projects using API key mode)
|
||||
</label>
|
||||
{hasKey ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--success)]">Key configured</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await removeApiKey(); } catch (e) { setError(String(e)); }
|
||||
}}
|
||||
className="text-xs text-[var(--error)] hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full 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)]"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !key.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Key"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
app/src/components/settings/DockerSettings.tsx
Normal file
72
app/src/components/settings/DockerSettings.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { useDocker } from "../../hooks/useDocker";
|
||||
|
||||
export default function DockerSettings() {
|
||||
const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage } =
|
||||
useDocker();
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleBuild = async () => {
|
||||
setBuilding(true);
|
||||
setBuildLog([]);
|
||||
setError(null);
|
||||
try {
|
||||
await buildImage((msg) => {
|
||||
setBuildLog((prev) => [...prev, msg]);
|
||||
});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Docker</label>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Docker Status</span>
|
||||
<span className={dockerAvailable ? "text-[var(--success)]" : "text-[var(--error)]"}>
|
||||
{dockerAvailable === null ? "Checking..." : dockerAvailable ? "Connected" : "Not Available"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Image</span>
|
||||
<span className={imageExists ? "text-[var(--success)]" : "text-[var(--text-secondary)]"}>
|
||||
{imageExists === null ? "Checking..." : imageExists ? "Built" : "Not Built"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => { await checkDocker(); await checkImage(); }}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Refresh Status
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={building || !dockerAvailable}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{building ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{buildLog.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||
{buildLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-[var(--error)]">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/src/components/settings/SettingsPanel.tsx
Normal file
14
app/src/components/settings/SettingsPanel.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ApiKeyInput from "./ApiKeyInput";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||
Settings
|
||||
</h2>
|
||||
<ApiKeyInput />
|
||||
<DockerSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
app/src/components/terminal/TerminalTabs.tsx
Normal file
41
app/src/components/terminal/TerminalTabs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
|
||||
export default function TerminalTabs() {
|
||||
const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||
No active terminals
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => setActiveSession(session.id)}
|
||||
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||
activeSessionId === session.id
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{session.projectName}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors"
|
||||
title="Close terminal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
app/src/components/terminal/TerminalView.tsx
Normal file
130
app/src/components/terminal/TerminalView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function TerminalView({ sessionId, active }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
foreground: "#e6edf3",
|
||||
cursor: "#58a6ff",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#484f58",
|
||||
red: "#ff7b72",
|
||||
green: "#3fb950",
|
||||
yellow: "#d29922",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39d353",
|
||||
white: "#b1bac4",
|
||||
brightBlack: "#6e7681",
|
||||
brightRed: "#ffa198",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d364",
|
||||
brightWhite: "#f0f6fc",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
// Web links addon — opens URLs in host browser via Tauri
|
||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Try WebGL renderer, fall back silently
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
|
||||
fitAddon.fit();
|
||||
termRef.current = term;
|
||||
fitRef.current = fitAddon;
|
||||
|
||||
// Send initial size
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Handle backend output -> terminal
|
||||
let unlistenOutput: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
|
||||
onOutput(sessionId, (data) => {
|
||||
term.write(data);
|
||||
}).then((unlisten) => {
|
||||
unlistenOutput = unlisten;
|
||||
});
|
||||
|
||||
onExit(sessionId, () => {
|
||||
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
||||
}).then((unlisten) => {
|
||||
unlistenExit = unlisten;
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose();
|
||||
unlistenOutput?.();
|
||||
unlistenExit?.();
|
||||
resizeObserver.disconnect();
|
||||
term.dispose();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit when tab becomes active
|
||||
useEffect(() => {
|
||||
if (active && fitRef.current && termRef.current) {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
||||
style={{ padding: "4px" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
app/src/hooks/useDocker.ts
Normal file
61
app/src/hooks/useDocker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useDocker() {
|
||||
const {
|
||||
dockerAvailable,
|
||||
setDockerAvailable,
|
||||
imageExists,
|
||||
setImageExists,
|
||||
} = useAppState();
|
||||
|
||||
const checkDocker = useCallback(async () => {
|
||||
try {
|
||||
const available = await commands.checkDocker();
|
||||
setDockerAvailable(available);
|
||||
return available;
|
||||
} catch {
|
||||
setDockerAvailable(false);
|
||||
return false;
|
||||
}
|
||||
}, [setDockerAvailable]);
|
||||
|
||||
const checkImage = useCallback(async () => {
|
||||
try {
|
||||
const exists = await commands.checkImageExists();
|
||||
setImageExists(exists);
|
||||
return exists;
|
||||
} catch {
|
||||
setImageExists(false);
|
||||
return false;
|
||||
}
|
||||
}, [setImageExists]);
|
||||
|
||||
const buildImage = useCallback(
|
||||
async (onProgress?: (msg: string) => void) => {
|
||||
const unlisten = onProgress
|
||||
? await listen<string>("image-build-progress", (event) => {
|
||||
onProgress(event.payload);
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
await commands.buildImage();
|
||||
setImageExists(true);
|
||||
} finally {
|
||||
unlisten?.();
|
||||
}
|
||||
},
|
||||
[setImageExists],
|
||||
);
|
||||
|
||||
return {
|
||||
dockerAvailable,
|
||||
imageExists,
|
||||
checkDocker,
|
||||
checkImage,
|
||||
buildImage,
|
||||
};
|
||||
}
|
||||
91
app/src/hooks/useProjects.ts
Normal file
91
app/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useProjects() {
|
||||
const {
|
||||
projects,
|
||||
selectedProjectId,
|
||||
setProjects,
|
||||
setSelectedProject,
|
||||
updateProjectInList,
|
||||
removeProjectFromList,
|
||||
} = useAppState();
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
}, [setProjects]);
|
||||
|
||||
const add = useCallback(
|
||||
async (name: string, path: string) => {
|
||||
const project = await commands.addProject(name, path);
|
||||
// Refresh from backend to avoid stale closure issues
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
setSelectedProject(project.id);
|
||||
return project;
|
||||
},
|
||||
[setProjects, setSelectedProject],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.removeProject(id);
|
||||
removeProjectFromList(id);
|
||||
},
|
||||
[removeProjectFromList],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (id: string) => {
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
},
|
||||
[setProjects],
|
||||
);
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (id: string) => {
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (project: Parameters<typeof commands.updateProject>[0]) => {
|
||||
const updated = await commands.updateProject(project);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
return {
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedProjectId,
|
||||
setSelectedProject,
|
||||
refresh,
|
||||
add,
|
||||
remove,
|
||||
start,
|
||||
stop,
|
||||
rebuild,
|
||||
update,
|
||||
};
|
||||
}
|
||||
38
app/src/hooks/useSettings.ts
Normal file
38
app/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useSettings() {
|
||||
const { hasKey, setHasKey } = useAppState();
|
||||
|
||||
const checkApiKey = useCallback(async () => {
|
||||
try {
|
||||
const has = await commands.hasApiKey();
|
||||
setHasKey(has);
|
||||
return has;
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
return false;
|
||||
}
|
||||
}, [setHasKey]);
|
||||
|
||||
const saveApiKey = useCallback(
|
||||
async (key: string) => {
|
||||
await commands.setApiKey(key);
|
||||
setHasKey(true);
|
||||
},
|
||||
[setHasKey],
|
||||
);
|
||||
|
||||
const removeApiKey = useCallback(async () => {
|
||||
await commands.deleteApiKey();
|
||||
setHasKey(false);
|
||||
}, [setHasKey]);
|
||||
|
||||
return {
|
||||
hasKey,
|
||||
checkApiKey,
|
||||
saveApiKey,
|
||||
removeApiKey,
|
||||
};
|
||||
}
|
||||
74
app/src/hooks/useTerminal.ts
Normal file
74
app/src/hooks/useTerminal.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useTerminal() {
|
||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||
useAppState();
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId);
|
||||
addSession({ id: sessionId, projectId, projectName });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
async (sessionId: string) => {
|
||||
await commands.closeTerminalSession(sessionId);
|
||||
removeSession(sessionId);
|
||||
},
|
||||
[removeSession],
|
||||
);
|
||||
|
||||
const sendInput = useCallback(
|
||||
async (sessionId: string, data: string) => {
|
||||
const bytes = Array.from(new TextEncoder().encode(data));
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resize = useCallback(
|
||||
async (sessionId: string, cols: number, rows: number) => {
|
||||
await commands.terminalResize(sessionId, cols, rows);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onOutput = useCallback(
|
||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||
const eventName = `terminal-output-${sessionId}`;
|
||||
return listen<number[]>(eventName, (event) => {
|
||||
callback(new Uint8Array(event.payload));
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onExit = useCallback(
|
||||
(sessionId: string, callback: () => void) => {
|
||||
const eventName = `terminal-exit-${sessionId}`;
|
||||
return listen<void>(eventName, () => {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSession,
|
||||
open,
|
||||
close,
|
||||
sendInput,
|
||||
resize,
|
||||
onOutput,
|
||||
onExit,
|
||||
};
|
||||
}
|
||||
52
app/src/index.css
Normal file
52
app/src/index.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--border-color: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
2
app/src/lib/constants.ts
Normal file
2
app/src/lib/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const APP_NAME = "Triple-C";
|
||||
export const IMAGE_NAME = "triple-c:latest";
|
||||
42
app/src/lib/tauri-commands.ts
Normal file
42
app/src/lib/tauri-commands.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ContainerInfo, SiblingContainer } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
export const checkImageExists = () => invoke<boolean>("check_image_exists");
|
||||
export const buildImage = () => invoke<void>("build_image");
|
||||
export const getContainerInfo = (projectId: string) =>
|
||||
invoke<ContainerInfo | null>("get_container_info", { projectId });
|
||||
export const listSiblingContainers = () =>
|
||||
invoke<SiblingContainer[]>("list_sibling_containers");
|
||||
|
||||
// Projects
|
||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||
export const addProject = (name: string, path: string) =>
|
||||
invoke<Project>("add_project", { name, path });
|
||||
export const removeProject = (projectId: string) =>
|
||||
invoke<void>("remove_project", { projectId });
|
||||
export const updateProject = (project: Project) =>
|
||||
invoke<Project>("update_project", { project });
|
||||
export const startProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("start_project_container", { projectId });
|
||||
export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
|
||||
// Settings
|
||||
export const setApiKey = (key: string) =>
|
||||
invoke<void>("set_api_key", { key });
|
||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||
export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
45
app/src/lib/types.ts
Normal file
45
app/src/lib/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
container_id: string | null;
|
||||
status: ProjectStatus;
|
||||
auth_mode: AuthMode;
|
||||
allow_docker_access: boolean;
|
||||
ssh_key_path: string | null;
|
||||
git_token: string | null;
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ProjectStatus =
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type AuthMode = "login" | "api_key";
|
||||
|
||||
export interface ContainerInfo {
|
||||
container_id: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface SiblingContainer {
|
||||
id: string;
|
||||
names: string[] | null;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
10
app/src/main.tsx
Normal file
10
app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
80
app/src/store/appState.ts
Normal file
80
app/src/store/appState.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { create } from "zustand";
|
||||
import type { Project, TerminalSession } from "../lib/types";
|
||||
|
||||
interface AppState {
|
||||
// Projects
|
||||
projects: Project[];
|
||||
selectedProjectId: string | null;
|
||||
setProjects: (projects: Project[]) => void;
|
||||
setSelectedProject: (id: string | null) => void;
|
||||
updateProjectInList: (project: Project) => void;
|
||||
removeProjectFromList: (id: string) => void;
|
||||
|
||||
// Terminal sessions
|
||||
sessions: TerminalSession[];
|
||||
activeSessionId: string | null;
|
||||
addSession: (session: TerminalSession) => void;
|
||||
removeSession: (id: string) => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
|
||||
// UI state
|
||||
sidebarView: "projects" | "settings";
|
||||
setSidebarView: (view: "projects" | "settings") => void;
|
||||
dockerAvailable: boolean | null;
|
||||
setDockerAvailable: (available: boolean | null) => void;
|
||||
imageExists: boolean | null;
|
||||
setImageExists: (exists: boolean | null) => void;
|
||||
hasKey: boolean | null;
|
||||
setHasKey: (has: boolean | null) => void;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set) => ({
|
||||
// Projects
|
||||
projects: [],
|
||||
selectedProjectId: null,
|
||||
setProjects: (projects) => set({ projects }),
|
||||
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
||||
updateProjectInList: (project) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((p) =>
|
||||
p.id === project.id ? project : p,
|
||||
),
|
||||
})),
|
||||
removeProjectFromList: (id) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
selectedProjectId:
|
||||
state.selectedProjectId === id ? null : state.selectedProjectId,
|
||||
})),
|
||||
|
||||
// Terminal sessions
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
addSession: (session) =>
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
activeSessionId: session.id,
|
||||
})),
|
||||
removeSession: (id) =>
|
||||
set((state) => {
|
||||
const sessions = state.sessions.filter((s) => s.id !== id);
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId:
|
||||
state.activeSessionId === id
|
||||
? (sessions[sessions.length - 1]?.id ?? null)
|
||||
: state.activeSessionId,
|
||||
};
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
|
||||
// UI state
|
||||
sidebarView: "projects",
|
||||
setSidebarView: (view) => set({ sidebarView: view }),
|
||||
dockerAvailable: null,
|
||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||
imageExists: null,
|
||||
setImageExists: (exists) => set({ imageExists: exists }),
|
||||
hasKey: null,
|
||||
setHasKey: (has) => set({ hasKey: has }),
|
||||
}));
|
||||
Reference in New Issue
Block a user