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:
44
.gitea/workflows/build.yml
Normal file
44
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
IMAGE_NAME: cybercovellc/triple-c/triple-c-sandbox
|
||||
|
||||
jobs:
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
app/dist/
|
||||
app/src-tauri/target/
|
||||
Screenshot*.png
|
||||
code-review.md
|
||||
125
BUILDING.md
Normal file
125
BUILDING.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Building Triple-C
|
||||
|
||||
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux and Windows.
|
||||
|
||||
## Prerequisites (All Platforms)
|
||||
|
||||
- **Node.js 22** LTS — https://nodejs.org/
|
||||
- **Rust** (stable) — https://rustup.rs/
|
||||
- **Git**
|
||||
|
||||
## Linux
|
||||
|
||||
### 1. Install system dependencies
|
||||
|
||||
Ubuntu / Debian:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
patchelf \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
build-essential
|
||||
```
|
||||
|
||||
Fedora:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
gtk3-devel \
|
||||
webkit2gtk4.1-devel \
|
||||
libayatana-appindicator-gtk3-devel \
|
||||
librsvg2-devel \
|
||||
libsoup3-devel \
|
||||
patchelf \
|
||||
openssl-devel \
|
||||
pkg-config \
|
||||
gcc
|
||||
```
|
||||
|
||||
Arch:
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed \
|
||||
gtk3 \
|
||||
webkit2gtk-4.1 \
|
||||
libayatana-appindicator \
|
||||
librsvg \
|
||||
libsoup3 \
|
||||
patchelf \
|
||||
openssl \
|
||||
pkg-config \
|
||||
base-devel
|
||||
```
|
||||
|
||||
### 2. Install frontend dependencies
|
||||
|
||||
```bash
|
||||
cd app
|
||||
npm ci
|
||||
```
|
||||
|
||||
### 3. Build
|
||||
|
||||
```bash
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
|
||||
|
||||
| Format | Path |
|
||||
|------------|-------------------------------|
|
||||
| AppImage | `appimage/*.AppImage` |
|
||||
| Debian pkg | `deb/*.deb` |
|
||||
| RPM pkg | `rpm/*.rpm` |
|
||||
|
||||
## Windows
|
||||
|
||||
### 1. Install prerequisites
|
||||
|
||||
- **Visual Studio Build Tools** or **Visual Studio** with the "Desktop development with C++" workload — https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
- **WebView2** — pre-installed on Windows 10 (1803+) and Windows 11. If missing, download the Evergreen Bootstrapper from https://developer.microsoft.com/en-us/microsoft-edge/webview2/
|
||||
|
||||
### 2. Install frontend dependencies
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
npm ci
|
||||
```
|
||||
|
||||
### 3. Build
|
||||
|
||||
```powershell
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
Build artifacts are located in `app\src-tauri\target\release\bundle\`:
|
||||
|
||||
| Format | Path |
|
||||
|--------|-------------------|
|
||||
| MSI | `msi\*.msi` |
|
||||
| NSIS | `nsis\*.exe` |
|
||||
|
||||
## Development Mode
|
||||
|
||||
To run the app in development mode with hot-reload:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
npm ci # if not already done
|
||||
npx tauri dev
|
||||
```
|
||||
|
||||
## Container Image
|
||||
|
||||
The sandbox container image (used at runtime by the app) is built automatically by CI when files under `container/` change. To build it locally:
|
||||
|
||||
```bash
|
||||
docker build -t triple-c-sandbox ./container
|
||||
```
|
||||
3
Triple-C.md
Normal file
3
Triple-C.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Triple-C (Claude-Code-Container)
|
||||
|
||||
Triple C is a container intended to limit what files Claude Code has access to, so when you run with `--dangerously-skip-permissions` Claude only has access to files/projects you provide to it.
|
||||
13
app/index.html
Normal file
13
app/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Triple-C</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2810
app/package-lock.json
generated
Normal file
2810
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
app/package.json
Normal file
37
app/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
"@xterm/xterm": "^5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4",
|
||||
"autoprefixer": "^10",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.7",
|
||||
"vite": "^6"
|
||||
}
|
||||
}
|
||||
5771
app/src-tauri/Cargo.lock
generated
Normal file
5771
app/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
app/src-tauri/Cargo.toml
Normal file
38
app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "triple_c_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "triple-c"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bollard = "0.18"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
dirs = "6"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
tar = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
app/src-tauri/build.rs
Normal file
3
app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
33
app/src-tauri/capabilities/default.json
Normal file
33
app/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Triple-C",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:event:allow-emit-to",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm",
|
||||
"store:default",
|
||||
"store:allow-get",
|
||||
"store:allow-set",
|
||||
"store:allow-delete",
|
||||
"store:allow-keys",
|
||||
"store:allow-values",
|
||||
"store:allow-entries",
|
||||
"store:allow-length",
|
||||
"store:allow-load",
|
||||
"store:allow-reset",
|
||||
"store:allow-save",
|
||||
"store:allow-clear",
|
||||
"opener:default",
|
||||
"opener:allow-open-url"
|
||||
]
|
||||
}
|
||||
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url"]}}
|
||||
2717
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2717
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2717
app/src-tauri/gen/schemas/linux-schema.json
Normal file
2717
app/src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/src-tauri/icons/128x128.png
Normal file
BIN
app/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 B |
BIN
app/src-tauri/icons/128x128@2x.png
Normal file
BIN
app/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 914 B |
BIN
app/src-tauri/icons/32x32.png
Normal file
BIN
app/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 B |
54
app/src-tauri/src/commands/docker_commands.rs
Normal file
54
app/src-tauri/src/commands/docker_commands.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::ContainerInfo;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_docker() -> Result<bool, String> {
|
||||
docker::check_docker_available().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_image_exists() -> Result<bool, String> {
|
||||
docker::image_exists().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_image(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
docker::build_image(move |msg| {
|
||||
let _ = app_handle.emit("image-build-progress", msg);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_container_info(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<ContainerInfo>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
||||
let containers = docker::list_sibling_containers().await?;
|
||||
let result: Vec<serde_json::Value> = containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"names": c.names,
|
||||
"image": c.image,
|
||||
"state": c.state,
|
||||
"status": c.status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
4
app/src-tauri/src/commands/mod.rs
Normal file
4
app/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod docker_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod terminal_commands;
|
||||
157
app/src-tauri/src/commands/project_commands.rs
Normal file
157
app/src-tauri/src/commands/project_commands.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{AuthMode, Project, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_project(
|
||||
name: String,
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let project = Project::new(name, path);
|
||||
state.projects_store.add(project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_project(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Stop and remove container if it exists
|
||||
if let Some(project) = state.projects_store.get(&project_id) {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
let _ = docker::remove_container(container_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any exec sessions
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
|
||||
state.projects_store.remove(&project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_project(
|
||||
project: Project,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
state.projects_store.update(project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_project_container(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let mut project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
// Get API key only if auth mode requires it
|
||||
let api_key = match project.auth_mode {
|
||||
AuthMode::ApiKey => {
|
||||
let key = secure::get_api_key()?
|
||||
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
|
||||
Some(key)
|
||||
}
|
||||
AuthMode::Login => {
|
||||
// Login mode: no API key needed, user runs `claude login` in the container.
|
||||
// Auth state persists in the .claude config volume.
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Update status to starting
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||
|
||||
// Ensure image exists
|
||||
if !docker::image_exists().await? {
|
||||
return Err("Docker image not built. Please build the image first.".to_string());
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = default_docker_socket();
|
||||
|
||||
// Check for existing container
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Start existing container
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
} else {
|
||||
// Create new container
|
||||
let new_id = docker::create_container(&project, api_key.as_deref(), &docker_socket).await?;
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
|
||||
// Update project with container info
|
||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
||||
|
||||
project.container_id = Some(container_id);
|
||||
project.status = ProjectStatus::Running;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_project_container(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||
|
||||
// Close exec sessions for this project
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
|
||||
docker::stop_container(container_id).await?;
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_project_container(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
// Remove existing container
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
state.exec_manager.close_all_sessions().await;
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
// Start fresh
|
||||
start_project_container(project_id, state).await
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
16
app/src-tauri/src/commands/settings_commands.rs
Normal file
16
app/src-tauri/src/commands/settings_commands.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::storage::secure;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
||||
secure::store_api_key(&key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn has_api_key() -> Result<bool, String> {
|
||||
secure::has_api_key()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_api_key() -> Result<(), String> {
|
||||
secure::delete_api_key()
|
||||
}
|
||||
74
app/src-tauri/src/commands/terminal_commands.rs
Normal file
74
app/src-tauri/src/commands/terminal_commands.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = vec![
|
||||
"claude".to_string(),
|
||||
"--dangerously-skip-permissions".to_string(),
|
||||
];
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
let exit_event = format!("terminal-exit-{}", session_id);
|
||||
let app_handle_output = app_handle.clone();
|
||||
let app_handle_exit = app_handle.clone();
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(
|
||||
container_id,
|
||||
&session_id,
|
||||
cmd,
|
||||
move |data| {
|
||||
let _ = app_handle_output.emit(&output_event, data);
|
||||
},
|
||||
Box::new(move || {
|
||||
let _ = app_handle_exit.emit(&exit_event, ());
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn terminal_input(
|
||||
session_id: String,
|
||||
data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.exec_manager.send_input(&session_id, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn terminal_resize(
|
||||
session_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.exec_manager.resize(&session_id, cols, rows).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_terminal_session(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
23
app/src-tauri/src/docker/client.rs
Normal file
23
app/src-tauri/src/docker/client.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use bollard::Docker;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
||||
|
||||
pub fn get_docker() -> Result<&'static Docker, String> {
|
||||
let result = DOCKER.get_or_init(|| {
|
||||
Docker::connect_with_local_defaults()
|
||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
|
||||
});
|
||||
match result {
|
||||
Ok(docker) => Ok(docker),
|
||||
Err(e) => Err(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_docker_available() -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
match docker.ping().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
|
||||
}
|
||||
}
|
||||
223
app/src-tauri/src/docker/container.rs
Normal file
223
app/src-tauri/src/docker/container.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{container_config, ContainerInfo, Project};
|
||||
|
||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([
|
||||
("name".to_string(), vec![container_name.clone()]),
|
||||
]);
|
||||
|
||||
let containers: Vec<ContainerSummary> = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
// Match exact name (Docker prepends /)
|
||||
let expected = format!("/{}", container_name);
|
||||
for c in &containers {
|
||||
if let Some(names) = &c.names {
|
||||
if names.iter().any(|n| n == &expected) {
|
||||
return Ok(c.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn create_container(
|
||||
project: &Project,
|
||||
api_key: Option<&str>,
|
||||
docker_socket_path: &str,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
let image = container_config::full_image_name();
|
||||
|
||||
let mut env_vars: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(key) = api_key {
|
||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
||||
}
|
||||
|
||||
if let Some(ref token) = project.git_token {
|
||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||
}
|
||||
if let Some(ref name) = project.git_user_name {
|
||||
env_vars.push(format!("GIT_USER_NAME={}", name));
|
||||
}
|
||||
if let Some(ref email) = project.git_user_email {
|
||||
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
||||
}
|
||||
|
||||
let mut mounts = vec![
|
||||
// Project directory -> /workspace
|
||||
Mount {
|
||||
target: Some("/workspace".to_string()),
|
||||
source: Some(project.path.clone()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
// Named volume for claude config persistence
|
||||
Mount {
|
||||
target: Some("/home/claude/.claude".to_string()),
|
||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||
typ: Some(MountTypeEnum::VOLUME),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
// SSH keys mount (read-only)
|
||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude/.ssh".to_string()),
|
||||
source: Some(ssh_path.clone()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Docker socket (only if allowed)
|
||||
if project.allow_docker_access {
|
||||
mounts.push(Mount {
|
||||
target: Some("/var/run/docker.sock".to_string()),
|
||||
source: Some(docker_socket_path.to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config {
|
||||
image: Some(image),
|
||||
hostname: Some("triple-c".to_string()),
|
||||
env: Some(env_vars),
|
||||
labels: Some(labels),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
host_config: Some(host_config),
|
||||
tty: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let options = CreateContainerOptions {
|
||||
name: container_name,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = docker
|
||||
.create_container(Some(options), config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create container: {}", e))?;
|
||||
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
pub async fn start_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.start_container(container_id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.stop_container(
|
||||
container_id,
|
||||
Some(StopContainerOptions { t: 10 }),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||
}
|
||||
|
||||
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
let docker = get_docker()?;
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => {
|
||||
let status = info
|
||||
.state
|
||||
.and_then(|s| s.status)
|
||||
.map(|s| format!("{:?}", s))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Ok(Some(ContainerInfo {
|
||||
container_id: container_id.clone(),
|
||||
project_id: project.id.clone(),
|
||||
status,
|
||||
image: container_config::full_image_name(),
|
||||
}))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let all_containers: Vec<ContainerSummary> = docker
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||
|
||||
// Filter out Triple-C managed containers
|
||||
let siblings: Vec<ContainerSummary> = all_containers
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if let Some(labels) = &c.labels {
|
||||
!labels.contains_key("triple-c.managed")
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(siblings)
|
||||
}
|
||||
183
app/src-tauri/src/docker/exec.rs
Normal file
183
app/src-tauri/src/docker/exec.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&self.exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
cmd: Some(cmd),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
let exec_id = exec.id.clone();
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec_id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, mut input } => {
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
StartExecResults::Detached => {
|
||||
return Err("Exec started in detached mode".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.resize(cols, rows).await
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
96
app/src-tauri/src/docker/image.rs
Normal file
96
app/src-tauri/src/docker/image.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use bollard::image::{BuildImageOptions, ListImagesOptions};
|
||||
use bollard::models::ImageSummary;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::container_config;
|
||||
|
||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||
|
||||
pub async fn image_exists() -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
let full_name = container_config::full_image_name();
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![full_name],
|
||||
)]);
|
||||
|
||||
let images: Vec<ImageSummary> = docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||
|
||||
Ok(!images.is_empty())
|
||||
}
|
||||
|
||||
pub async fn build_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
let full_name = container_config::full_image_name();
|
||||
|
||||
// Create a tar archive in memory containing Dockerfile and entrypoint.sh
|
||||
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: full_name.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if let Some(stream) = output.stream {
|
||||
on_progress(stream);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
return Err(format!("Build error: {}", error));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Build stream error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut archive = tar::Builder::new(&mut buf);
|
||||
|
||||
// Add Dockerfile
|
||||
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(dockerfile_bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
||||
|
||||
// Add entrypoint.sh
|
||||
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(entrypoint_bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
|
||||
// Flush to make sure all data is written
|
||||
let _ = buf.flush();
|
||||
Ok(buf)
|
||||
}
|
||||
9
app/src-tauri/src/docker/mod.rs
Normal file
9
app/src-tauri/src/docker/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod client;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
|
||||
pub use client::*;
|
||||
pub use container::*;
|
||||
pub use image::*;
|
||||
pub use exec::*;
|
||||
52
app/src-tauri/src/lib.rs
Normal file
52
app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
mod commands;
|
||||
mod docker;
|
||||
mod models;
|
||||
mod storage;
|
||||
|
||||
use docker::exec::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
|
||||
pub struct AppState {
|
||||
pub projects_store: ProjectsStore,
|
||||
pub exec_manager: ExecSessionManager,
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
projects_store: ProjectsStore::new(),
|
||||
exec_manager: ExecSessionManager::new(),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Docker
|
||||
commands::docker_commands::check_docker,
|
||||
commands::docker_commands::check_image_exists,
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
commands::project_commands::remove_project,
|
||||
commands::project_commands::update_project,
|
||||
commands::project_commands::start_project_container,
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
// Settings
|
||||
commands::settings_commands::set_api_key,
|
||||
commands::settings_commands::has_api_key,
|
||||
commands::settings_commands::delete_api_key,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
app/src-tauri/src/main.rs
Normal file
6
app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
triple_c_lib::run()
|
||||
}
|
||||
20
app/src-tauri/src/models/app_settings.rs
Normal file
20
app/src-tauri/src/models/app_settings.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
pub default_ssh_key_path: Option<String>,
|
||||
pub default_git_user_name: Option<String>,
|
||||
pub default_git_user_email: Option<String>,
|
||||
pub docker_socket_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_ssh_key_path: None,
|
||||
default_git_user_name: None,
|
||||
default_git_user_email: None,
|
||||
docker_socket_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/src-tauri/src/models/container_config.rs
Normal file
16
app/src-tauri/src/models/container_config.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerInfo {
|
||||
pub container_id: String,
|
||||
pub project_id: String,
|
||||
pub status: String,
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
pub const IMAGE_NAME: &str = "triple-c";
|
||||
pub const IMAGE_TAG: &str = "latest";
|
||||
|
||||
pub fn full_image_name() -> String {
|
||||
format!("{IMAGE_NAME}:{IMAGE_TAG}")
|
||||
}
|
||||
7
app/src-tauri/src/models/mod.rs
Normal file
7
app/src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
69
app/src-tauri/src/models/project.rs
Normal file
69
app/src-tauri/src/models/project.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub container_id: Option<String>,
|
||||
pub status: ProjectStatus,
|
||||
pub auth_mode: AuthMode,
|
||||
pub allow_docker_access: bool,
|
||||
pub ssh_key_path: Option<String>,
|
||||
pub git_token: Option<String>,
|
||||
pub git_user_name: Option<String>,
|
||||
pub git_user_email: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProjectStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// How the project authenticates with Claude.
|
||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
Login,
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
impl Default for AuthMode {
|
||||
fn default() -> Self {
|
||||
Self::Login
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, path: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
path,
|
||||
container_id: None,
|
||||
status: ProjectStatus::Stopped,
|
||||
auth_mode: AuthMode::default(),
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: None,
|
||||
git_token: None,
|
||||
git_user_name: None,
|
||||
git_user_email: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn container_name(&self) -> String {
|
||||
format!("triple-c-{}", self.id)
|
||||
}
|
||||
}
|
||||
5
app/src-tauri/src/storage/mod.rs
Normal file
5
app/src-tauri/src/storage/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
|
||||
pub use projects_store::*;
|
||||
pub use secure::*;
|
||||
129
app/src-tauri/src/storage/projects_store.rs
Normal file
129
app/src-tauri/src/storage/projects_store.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::Project;
|
||||
|
||||
pub struct ProjectsStore {
|
||||
projects: Mutex<Vec<Project>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectsStore {
|
||||
pub fn new() -> Self {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("triple-c");
|
||||
|
||||
fs::create_dir_all(&data_dir).ok();
|
||||
|
||||
let file_path = data_dir.join("projects.json");
|
||||
|
||||
let projects = if file_path.exists() {
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(data) => match serde_json::from_str(&data) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||
// Back up the corrupted file
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to read projects.json: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
projects: Mutex::new(projects),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||
self.projects.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn save(&self, projects: &[Project]) -> Result<(), String> {
|
||||
let data = serde_json::to_string_pretty(projects)
|
||||
.map_err(|e| format!("Failed to serialize projects: {}", e))?;
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
let tmp_path = self.file_path.with_extension("json.tmp");
|
||||
fs::write(&tmp_path, data)
|
||||
.map_err(|e| format!("Failed to write temp projects file: {}", e))?;
|
||||
fs::rename(&tmp_path, &self.file_path)
|
||||
.map_err(|e| format!("Failed to rename projects file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<Project> {
|
||||
self.lock().clone()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Project> {
|
||||
self.lock().iter().find(|p| p.id == id).cloned()
|
||||
}
|
||||
|
||||
pub fn add(&self, project: Project) -> Result<Project, String> {
|
||||
let mut projects = self.lock();
|
||||
let cloned = project.clone();
|
||||
projects.push(project);
|
||||
self.save(&projects)?;
|
||||
Ok(cloned)
|
||||
}
|
||||
|
||||
pub fn update(&self, updated: Project) -> Result<Project, String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == updated.id) {
|
||||
*p = updated.clone();
|
||||
self.save(&projects)?;
|
||||
Ok(updated)
|
||||
} else {
|
||||
Err(format!("Project {} not found", updated.id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
let initial_len = projects.len();
|
||||
projects.retain(|p| p.id != id);
|
||||
if projects.len() == initial_len {
|
||||
return Err(format!("Project {} not found", id));
|
||||
}
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == id) {
|
||||
p.status = status;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Project {} not found", id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
p.container_id = container_id;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.save(&projects)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Project {} not found", project_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/src-tauri/src/storage/secure.rs
Normal file
38
app/src-tauri/src/storage/secure.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
const SERVICE_NAME: &str = "triple-c";
|
||||
const API_KEY_USER: &str = "anthropic-api-key";
|
||||
|
||||
pub fn store_api_key(key: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(key)
|
||||
.map_err(|e| format!("Failed to store API key: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_api_key() -> Result<Option<String>, String> {
|
||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.get_password() {
|
||||
Ok(key) => Ok(Some(key)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_api_key() -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete API key: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_api_key() -> Result<bool, String> {
|
||||
match get_api_key() {
|
||||
Ok(Some(_)) => Ok(true),
|
||||
Ok(None) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
38
app/src-tauri/tauri.conf.json
Normal file
38
app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.triple-c.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Triple-C",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png"
|
||||
]
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||
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 }),
|
||||
}));
|
||||
21
app/tsconfig.json
Normal file
21
app/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
app/vite.config.ts
Normal file
25
app/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
94
container/Dockerfile
Normal file
94
container/Dockerfile
Normal file
@@ -0,0 +1,94 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# Avoid interactive prompts during package install
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── System packages ──────────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
openssh-client \
|
||||
build-essential \
|
||||
ripgrep \
|
||||
jq \
|
||||
sudo \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
locales \
|
||||
unzip \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set UTF-8 locale
|
||||
RUN locale-gen en_US.UTF-8
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
# ── GitHub CLI ───────────────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Node.js LTS (22.x) + pnpm ───────────────────────────────────────────────
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install -g pnpm
|
||||
|
||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
||||
|
||||
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
|
||||
RUN install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
> /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update && apt-get install -y docker-ce-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
||||
RUN useradd -m -s /bin/bash claude \
|
||||
&& echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \
|
||||
&& chmod 0440 /etc/sudoers.d/claude
|
||||
|
||||
# ── Mount points (created as root, owned by claude) ──────────────────────────
|
||||
RUN mkdir -p /workspace && chown claude:claude /workspace
|
||||
|
||||
# ── Rust (installed as claude user) ──────────────────────────────────────────
|
||||
USER claude
|
||||
WORKDIR /home/claude
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/home/claude/.cargo/bin:${PATH}"
|
||||
|
||||
# Add uv/ruff to PATH (installed to /root by default, reinstall for claude user)
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
||||
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Claude Code ──────────────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
ENV PATH="/home/claude/.claude/bin:${PATH}"
|
||||
|
||||
RUN mkdir -p /home/claude/.claude /home/claude/.ssh
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --chown=claude:claude entrypoint.sh /home/claude/entrypoint.sh
|
||||
RUN chmod +x /home/claude/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/home/claude/entrypoint.sh"]
|
||||
49
container/entrypoint.sh
Normal file
49
container/entrypoint.sh
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────────────
|
||||
# If SSH keys were mounted, fix permissions (bind mounts may have wrong perms)
|
||||
if [ -d /home/claude/.ssh ]; then
|
||||
chmod 700 /home/claude/.ssh
|
||||
find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \;
|
||||
find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \;
|
||||
# Write known_hosts fresh (not append) to avoid duplicates across restarts
|
||||
ssh-keyscan -t ed25519,rsa github.com gitlab.com bitbucket.org > /home/claude/.ssh/known_hosts 2>/dev/null || true
|
||||
chmod 644 /home/claude/.ssh/known_hosts
|
||||
fi
|
||||
|
||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||
if [ -n "$GIT_TOKEN" ]; then
|
||||
# Use git credential-store with a protected file instead of embedding in config
|
||||
CRED_FILE="/home/claude/.git-credentials"
|
||||
: > "$CRED_FILE"
|
||||
chmod 600 "$CRED_FILE"
|
||||
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE"
|
||||
echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE"
|
||||
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
|
||||
git config --global credential.helper "store --file=$CRED_FILE"
|
||||
# Clear the env var so it's not visible in /proc/*/environ
|
||||
unset GIT_TOKEN
|
||||
fi
|
||||
|
||||
# ── Git user config ──────────────────────────────────────────────────────────
|
||||
if [ -n "$GIT_USER_NAME" ]; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
fi
|
||||
if [ -n "$GIT_USER_EMAIL" ]; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
if ! getent group "$DOCKER_GID" > /dev/null 2>&1; then
|
||||
sudo groupadd -g "$DOCKER_GID" docker-host
|
||||
fi
|
||||
DOCKER_GROUP=$(getent group "$DOCKER_GID" | cut -d: -f1)
|
||||
sudo usermod -aG "$DOCKER_GROUP" claude
|
||||
fi
|
||||
|
||||
# ── Stay alive ───────────────────────────────────────────────────────────────
|
||||
echo "Triple-C container ready."
|
||||
exec sleep infinity
|
||||
Reference in New Issue
Block a user