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