Compare commits
15 Commits
v0.1.62-ma
...
v0.1.77
| Author | SHA1 | Date | |
|---|---|---|---|
| 625d48a6ed | |||
| 2ddc705925 | |||
| 1aced2d860 | |||
| 652e451afe | |||
| eb86aa95b7 | |||
| 3228e6cdd7 | |||
| 3344ce1cbf | |||
| d642cc64de | |||
| e3502876eb | |||
| 4f41f0d98b | |||
| c9dc232fc4 | |||
| 2d4fce935f | |||
| e739f6aaff | |||
| 550159fc63 | |||
| e3c874bc75 |
84
.gitea/workflows/backfill-releases.yml
Normal file
84
.gitea/workflows/backfill-releases.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Backfill Releases to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
backfill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Backfill all Gitea releases to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
GITEA_API: https://repo.anhonesthost.net/api/v1
|
||||
GITEA_REPO: cybercovellc/triple-c
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Fetching releases from Gitea..."
|
||||
RELEASES=$(curl -sf \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API/repos/$GITEA_REPO/releases?limit=50")
|
||||
|
||||
echo "$RELEASES" | jq -c '.[]' | while read release; do
|
||||
TAG=$(echo "$release" | jq -r '.tag_name')
|
||||
NAME=$(echo "$release" | jq -r '.name')
|
||||
BODY=$(echo "$release" | jq -r '.body')
|
||||
IS_PRERELEASE=$(echo "$release" | jq -r '.prerelease')
|
||||
IS_DRAFT=$(echo "$release" | jq -r '.draft')
|
||||
|
||||
EXISTS=$(curl -sf \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG" \
|
||||
-o /dev/null -w "%{http_code}" || true)
|
||||
|
||||
if [ "$EXISTS" = "200" ]; then
|
||||
echo "==> Skipping $TAG (already exists on GitHub)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "==> Creating release $TAG..."
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"$NAME\",
|
||||
\"body\": $(echo "$BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
|
||||
echo "$release" | jq -c '.assets[]?' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_ID=$(echo "$asset" | jq -r '.id')
|
||||
|
||||
echo " ==> Downloading $ASSET_NAME..."
|
||||
DOWNLOAD_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$DOWNLOAD_URL"
|
||||
|
||||
echo " ==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Done: $TAG"
|
||||
done
|
||||
|
||||
echo "==> Backfill complete."
|
||||
@@ -20,15 +20,29 @@ jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Node.js
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
NEED_INSTALL=false
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
echo "Node.js already installed: $(node --version)"
|
||||
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||
OLD_NODE_DIR=$(dirname "$(which node)")
|
||||
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
|
||||
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
|
||||
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
|
||||
hash -r
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
else
|
||||
echo "Installing Node.js 22..."
|
||||
echo "Node.js not found, installing 22..."
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
@@ -81,22 +95,27 @@ jobs:
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: npm ci
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
run: npx tauri --version || npm install @tauri-apps/cli
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri --version || npm install @tauri-apps/cli
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
run: npx tauri build
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
@@ -135,12 +154,21 @@ jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Install Node.js
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
if command -v node &>/dev/null; then
|
||||
echo "Node.js already installed: $(node --version)"
|
||||
NEED_INSTALL=false
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
|
||||
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
|
||||
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
else
|
||||
echo "Installing Node.js 22 via Homebrew..."
|
||||
echo "Node.js not found, installing 22..."
|
||||
NEED_INSTALL=true
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
brew install node@22
|
||||
brew link --overwrite node@22
|
||||
fi
|
||||
@@ -177,23 +205,28 @@ jobs:
|
||||
else
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./app
|
||||
run: npm ci
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
|
||||
- name: Install Tauri CLI
|
||||
working-directory: ./app
|
||||
run: npx tauri --version || npm install @tauri-apps/cli
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri --version || npm install @tauri-apps/cli
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
run: npx tauri build --target universal-apple-darwin
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
|
||||
60
.gitea/workflows/sync-release.yml
Normal file
60
.gitea/workflows/sync-release.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
60
TODO.md
Normal file
60
TODO.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# TODO / Future Improvements
|
||||
|
||||
## In-App Auto-Update via `tauri-plugin-updater`
|
||||
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
Currently the app detects available updates via the Gitea API (`check_for_updates` command) but cannot apply them. Users must manually download and install the new version. On macOS and Linux this is a poor experience compared to Windows (where NSIS handles upgrades cleanly).
|
||||
|
||||
### Recommended approach: `tauri-plugin-updater`
|
||||
|
||||
Full in-app auto-update: detects, downloads, verifies, and applies updates seamlessly on all platforms. The user clicks "Update" and the app restarts with the new version.
|
||||
|
||||
### Requirements
|
||||
|
||||
1. **Generate a Tauri update signing key pair** (this is Tauri's own Ed25519 key, not OS code signing):
|
||||
```bash
|
||||
npx @tauri-apps/cli signer generate -w ~/.tauri/triple-c.key
|
||||
```
|
||||
Set `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` in CI.
|
||||
|
||||
2. **Add `tauri-plugin-updater`** to Rust and JS dependencies.
|
||||
|
||||
3. **Create an update endpoint** that returns Tauri's expected JSON format:
|
||||
```json
|
||||
{
|
||||
"version": "v0.1.100",
|
||||
"notes": "Changelog here",
|
||||
"pub_date": "2026-03-01T00:00:00Z",
|
||||
"platforms": {
|
||||
"darwin-x86_64": { "signature": "...", "url": "https://..." },
|
||||
"darwin-aarch64": { "signature": "...", "url": "https://..." },
|
||||
"linux-x86_64": { "signature": "...", "url": "https://..." },
|
||||
"windows-x86_64": { "signature": "...", "url": "https://..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
This could be a static JSON file uploaded alongside release assets, or a small API that reads from Gitea releases and reformats.
|
||||
|
||||
4. **Configure the updater** in `tauri.conf.json`:
|
||||
```json
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": ["https://repo.anhonesthost.net/...update-endpoint..."],
|
||||
"pubkey": "<public key from step 1>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Add frontend UI** for the update prompt (replace or enhance the existing update check flow).
|
||||
|
||||
6. **Update CI pipeline** to:
|
||||
- Sign bundles with the Tauri key during build
|
||||
- Upload `.sig` files alongside installers
|
||||
- Generate/upload the update endpoint JSON
|
||||
|
||||
### References
|
||||
- https://v2.tauri.app/plugin/updater/
|
||||
- Existing update check code: `app/src-tauri/src/commands/update_commands.rs`
|
||||
- Existing models: `app/src-tauri/src/models/update_info.rs`
|
||||
38
app/src-tauri/src/commands/mcp_commands.rs
Normal file
38
app/src-tauri/src/commands/mcp_commands.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::models::McpServer;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
|
||||
Ok(state.mcp_store.list())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_mcp_server(
|
||||
name: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<McpServer, String> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("MCP server name cannot be empty.".to_string());
|
||||
}
|
||||
let server = McpServer::new(name);
|
||||
state.mcp_store.add(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_mcp_server(
|
||||
server: McpServer,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<McpServer, String> {
|
||||
state.mcp_store.update(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_mcp_server(
|
||||
server_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.mcp_store.remove(&server_id)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod docker_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod terminal_commands;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
|
||||
let _ = app_handle.emit(
|
||||
"container-progress",
|
||||
serde_json::json!({
|
||||
"project_id": project_id,
|
||||
"message": message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract secret fields from a project and store them in the OS keychain.
|
||||
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||
if let Some(ref token) = project.git_token {
|
||||
@@ -81,12 +91,19 @@ pub async fn remove_project(
|
||||
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 project) = state.projects_store.get(&project_id) {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
let _ = docker::remove_container(container_id).await;
|
||||
}
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -109,6 +126,7 @@ pub async fn update_project(
|
||||
#[tauri::command]
|
||||
pub async fn start_project_container(
|
||||
project_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let mut project = state
|
||||
@@ -124,6 +142,12 @@ pub async fn start_project_container(
|
||||
let settings = state.settings_store.get();
|
||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Resolve enabled MCP servers for this project
|
||||
let all_mcp_servers = state.mcp_store.list();
|
||||
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
|
||||
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
|
||||
.collect();
|
||||
|
||||
// Validate auth mode requirements
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
@@ -140,6 +164,7 @@ pub async fn start_project_container(
|
||||
// Wrap container operations so that any failure resets status to Stopped.
|
||||
let result: Result<String, String> = async {
|
||||
// Ensure image exists
|
||||
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
@@ -153,48 +178,80 @@ pub async fn start_project_container(
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// Check for existing container
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
let needs_recreation = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if needs_recreation {
|
||||
log::info!("Container config changed, recreating container for project {}", project.id);
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes)
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container(
|
||||
&project,
|
||||
&docker_socket,
|
||||
&image_name,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
} else {
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
snapshot_image
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = docker::create_container(
|
||||
&project,
|
||||
&docker_socket,
|
||||
&image_name,
|
||||
&create_image,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
@@ -222,6 +279,7 @@ pub async fn start_project_container(
|
||||
#[tauri::command]
|
||||
pub async fn stop_project_container(
|
||||
project_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state
|
||||
@@ -229,22 +287,26 @@ pub async fn stop_project_container(
|
||||
.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)?;
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Close exec sessions for this project
|
||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
|
||||
docker::stop_container(container_id).await?;
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||
if let Err(e) = docker::stop_container(container_id).await {
|
||||
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rebuild_project_container(
|
||||
project_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
let project = state
|
||||
@@ -260,8 +322,16 @@ pub async fn rebuild_project_container(
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
// Remove snapshot image + volumes so Reset creates from the clean base image
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(&project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
// Start fresh
|
||||
start_project_container(project_id, state).await
|
||||
start_project_container(project_id, app_handle, state).await
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
use bollard::Docker;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
|
||||
static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
|
||||
|
||||
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 fn get_docker() -> Result<Docker, String> {
|
||||
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||
if let Some(docker) = guard.as_ref() {
|
||||
return Ok(docker.clone());
|
||||
}
|
||||
let docker = Docker::connect_with_local_defaults()
|
||||
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
|
||||
guard.replace(docker.clone());
|
||||
Ok(docker)
|
||||
}
|
||||
|
||||
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)),
|
||||
Err(_) => {
|
||||
// Connection object exists but daemon not responding — clear cache
|
||||
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
|
||||
*guard = None;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
|
||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
@@ -40,6 +41,42 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
|
||||
### Timezone
|
||||
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||
|
||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
/// the same value is produced in both paths.
|
||||
fn build_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
port_mappings: &[PortMapping],
|
||||
) -> Option<String> {
|
||||
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
|
||||
|
||||
if !port_mappings.is_empty() {
|
||||
let mut port_lines: Vec<String> = Vec::new();
|
||||
port_lines.push("## Available Port Mappings".to_string());
|
||||
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||
for pm in port_mappings {
|
||||
port_lines.push(format!(
|
||||
"- Host port {} -> Container port {} ({})",
|
||||
pm.host_port, pm.container_port, pm.protocol
|
||||
));
|
||||
}
|
||||
let port_info = port_lines.join("\n");
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||
None => port_info,
|
||||
});
|
||||
}
|
||||
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, SCHEDULER_INSTRUCTIONS),
|
||||
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
||||
});
|
||||
|
||||
combined
|
||||
}
|
||||
|
||||
/// Compute a fingerprint string for the custom environment variables.
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
@@ -139,6 +176,61 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||
format!("{:x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
|
||||
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
|
||||
fn build_mcp_servers_json(servers: &[McpServer]) -> String {
|
||||
let mut mcp_map = serde_json::Map::new();
|
||||
for server in servers {
|
||||
let mut entry = serde_json::Map::new();
|
||||
match server.transport_type {
|
||||
McpTransportType::Stdio => {
|
||||
entry.insert("type".to_string(), serde_json::json!("stdio"));
|
||||
if let Some(ref cmd) = server.command {
|
||||
entry.insert("command".to_string(), serde_json::json!(cmd));
|
||||
}
|
||||
if !server.args.is_empty() {
|
||||
entry.insert("args".to_string(), serde_json::json!(server.args));
|
||||
}
|
||||
if !server.env.is_empty() {
|
||||
entry.insert("env".to_string(), serde_json::json!(server.env));
|
||||
}
|
||||
}
|
||||
McpTransportType::Http => {
|
||||
entry.insert("type".to_string(), serde_json::json!("http"));
|
||||
if let Some(ref url) = server.url {
|
||||
entry.insert("url".to_string(), serde_json::json!(url));
|
||||
}
|
||||
if !server.headers.is_empty() {
|
||||
entry.insert("headers".to_string(), serde_json::json!(server.headers));
|
||||
}
|
||||
}
|
||||
McpTransportType::Sse => {
|
||||
entry.insert("type".to_string(), serde_json::json!("sse"));
|
||||
if let Some(ref url) = server.url {
|
||||
entry.insert("url".to_string(), serde_json::json!(url));
|
||||
}
|
||||
if !server.headers.is_empty() {
|
||||
entry.insert("headers".to_string(), serde_json::json!(server.headers));
|
||||
}
|
||||
}
|
||||
}
|
||||
mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry));
|
||||
}
|
||||
let wrapper = serde_json::json!({ "mcpServers": mcp_map });
|
||||
serde_json::to_string(&wrapper).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for MCP server configuration so we can detect changes.
|
||||
fn compute_mcp_fingerprint(servers: &[McpServer]) -> String {
|
||||
if servers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let json = build_mcp_servers_json(servers);
|
||||
let mut hasher = DefaultHasher::new();
|
||||
json.hash(&mut hasher);
|
||||
format!("{:x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -178,6 +270,7 @@ pub async fn create_container(
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -307,38 +400,23 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info)
|
||||
let mut combined_instructions = merge_claude_instructions(
|
||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
);
|
||||
if !project.port_mappings.is_empty() {
|
||||
let mut port_lines: Vec<String> = Vec::new();
|
||||
port_lines.push("## Available Port Mappings".to_string());
|
||||
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||
for pm in &project.port_mappings {
|
||||
port_lines.push(format!(
|
||||
"- Host port {} -> Container port {} ({})",
|
||||
pm.host_port, pm.container_port, pm.protocol
|
||||
));
|
||||
}
|
||||
let port_info = port_lines.join("\n");
|
||||
combined_instructions = Some(match combined_instructions {
|
||||
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||
None => port_info,
|
||||
});
|
||||
}
|
||||
// Scheduler instructions (always appended so all containers get scheduling docs)
|
||||
let scheduler_docs = SCHEDULER_INSTRUCTIONS;
|
||||
combined_instructions = Some(match combined_instructions {
|
||||
Some(existing) => format!("{}\n\n{}", existing, scheduler_docs),
|
||||
None => scheduler_docs.to_string(),
|
||||
});
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||
}
|
||||
|
||||
// MCP servers config
|
||||
if !mcp_servers.is_empty() {
|
||||
let mcp_json = build_mcp_servers_json(mcp_servers);
|
||||
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
|
||||
}
|
||||
|
||||
let mut mounts: Vec<Mount> = Vec::new();
|
||||
|
||||
// Project directories -> /workspace/{mount_name}
|
||||
@@ -352,7 +430,19 @@ pub async fn create_container(
|
||||
});
|
||||
}
|
||||
|
||||
// Named volume for claude config persistence
|
||||
// Named volume for the entire home directory — preserves ~/.claude.json,
|
||||
// ~/.local (pip/npm globals), and any other user-level state across
|
||||
// container stop/start cycles.
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude".to_string()),
|
||||
source: Some(format!("triple-c-home-{}", project.id)),
|
||||
typ: Some(MountTypeEnum::VOLUME),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Named volume for claude config persistence — mounted as a nested volume
|
||||
// inside the home volume; Docker gives the more-specific mount precedence.
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude/.claude".to_string()),
|
||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||
@@ -446,6 +536,7 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
@@ -523,6 +614,83 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||
}
|
||||
|
||||
/// Return the snapshot image name for a project.
|
||||
pub fn get_snapshot_image_name(project: &Project) -> String {
|
||||
format!("triple-c-snapshot-{}:latest", project.id)
|
||||
}
|
||||
|
||||
/// Commit the container's filesystem to a snapshot image so that system-level
|
||||
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
||||
/// removal. The Config is left empty so that secrets injected as env vars are
|
||||
/// NOT baked into the image.
|
||||
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_snapshot_image_name(project);
|
||||
|
||||
// Parse repo:tag
|
||||
let (repo, tag) = match image_name.rsplit_once(':') {
|
||||
Some((r, t)) => (r.to_string(), t.to_string()),
|
||||
None => (image_name.clone(), "latest".to_string()),
|
||||
};
|
||||
|
||||
let options = CommitContainerOptions {
|
||||
container: container_id.to_string(),
|
||||
repo: repo.clone(),
|
||||
tag: tag.clone(),
|
||||
pause: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Empty config — no env vars / cmd baked in
|
||||
let config = Config::<String> {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
docker
|
||||
.commit_container(options, config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
|
||||
|
||||
log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the snapshot image for a project (used on Reset / project removal).
|
||||
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_snapshot_image_name(project);
|
||||
|
||||
docker
|
||||
.remove_image(
|
||||
&image_name,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
||||
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove both named volumes for a project (used on Reset / project removal).
|
||||
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
for vol in [
|
||||
format!("triple-c-home-{}", project.id),
|
||||
format!("triple-c-claude-config-{}", project.id),
|
||||
] {
|
||||
match docker.remove_volume(&vol, None).await {
|
||||
Ok(_) => log::info!("Removed volume {}", vol),
|
||||
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether the existing container's configuration still matches the
|
||||
/// current project settings. Returns `true` when the container must be
|
||||
/// recreated (mounts or env vars differ).
|
||||
@@ -532,6 +700,7 @@ pub async fn container_needs_recreation(
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
let info = docker
|
||||
@@ -685,9 +854,10 @@ pub async fn container_needs_recreation(
|
||||
}
|
||||
|
||||
// ── Claude instructions ───────────────────────────────────────────────
|
||||
let expected_instructions = merge_claude_instructions(
|
||||
let expected_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
);
|
||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||
@@ -695,6 +865,14 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── MCP servers fingerprint ─────────────────────────────────────────
|
||||
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
|
||||
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
|
||||
if container_mcp_fp != expected_mcp_fp {
|
||||
log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ mod storage;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use storage::mcp_store::McpStore;
|
||||
use tauri::Manager;
|
||||
|
||||
pub struct AppState {
|
||||
pub projects_store: ProjectsStore,
|
||||
pub settings_store: SettingsStore,
|
||||
pub mcp_store: McpStore,
|
||||
pub exec_manager: ExecSessionManager,
|
||||
}
|
||||
|
||||
@@ -32,6 +34,13 @@ pub fn run() {
|
||||
panic!("Failed to initialize settings store: {}", e);
|
||||
}
|
||||
};
|
||||
let mcp_store = match McpStore::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to initialize MCP store: {}", e);
|
||||
panic!("Failed to initialize MCP store: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
@@ -40,6 +49,7 @@ pub fn run() {
|
||||
.manage(AppState {
|
||||
projects_store,
|
||||
settings_store,
|
||||
mcp_store,
|
||||
exec_manager: ExecSessionManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
@@ -91,6 +101,11 @@ pub fn run() {
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
// MCP
|
||||
commands::mcp_commands::list_mcp_servers,
|
||||
commands::mcp_commands::add_mcp_server,
|
||||
commands::mcp_commands::update_mcp_server,
|
||||
commands::mcp_commands::remove_mcp_server,
|
||||
// Updates
|
||||
commands::update_commands::get_app_version,
|
||||
commands::update_commands::check_for_updates,
|
||||
|
||||
52
app/src-tauri/src/models/mcp_server.rs
Normal file
52
app/src-tauri/src/models/mcp_server.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpTransportType {
|
||||
Stdio,
|
||||
Http,
|
||||
Sse,
|
||||
}
|
||||
|
||||
impl Default for McpTransportType {
|
||||
fn default() -> Self {
|
||||
Self::Stdio
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub transport_type: McpTransportType,
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(name: String) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
transport_type: McpTransportType::default(),
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
url: None,
|
||||
headers: HashMap::new(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ pub mod project;
|
||||
pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod update_info;
|
||||
pub mod mcp_server;
|
||||
|
||||
pub use project::*;
|
||||
pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use update_info::*;
|
||||
pub use mcp_server::*;
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct Project {
|
||||
pub port_mappings: Vec<PortMapping>,
|
||||
#[serde(default)]
|
||||
pub claude_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled_mcp_servers: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -130,6 +132,7 @@ impl Project {
|
||||
custom_env_vars: Vec::new(),
|
||||
port_mappings: Vec::new(),
|
||||
claude_instructions: None,
|
||||
enabled_mcp_servers: Vec::new(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
106
app/src-tauri/src/storage/mcp_store.rs
Normal file
106
app/src-tauri/src/storage/mcp_store.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::McpServer;
|
||||
|
||||
pub struct McpStore {
|
||||
servers: Mutex<Vec<McpServer>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl McpStore {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
||||
.join("triple-c");
|
||||
|
||||
fs::create_dir_all(&data_dir).ok();
|
||||
|
||||
let file_path = data_dir.join("mcp_servers.json");
|
||||
|
||||
let servers = if file_path.exists() {
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(data) => {
|
||||
match serde_json::from_str::<Vec<McpServer>>(&data) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted mcp_servers.json: {}", be);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to read mcp_servers.json: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
servers: Mutex::new(servers),
|
||||
file_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<McpServer>> {
|
||||
self.servers.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn save(&self, servers: &[McpServer]) -> Result<(), String> {
|
||||
let data = serde_json::to_string_pretty(servers)
|
||||
.map_err(|e| format!("Failed to serialize MCP servers: {}", 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 MCP servers file: {}", e))?;
|
||||
fs::rename(&tmp_path, &self.file_path)
|
||||
.map_err(|e| format!("Failed to rename MCP servers file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<McpServer> {
|
||||
self.lock().clone()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<McpServer> {
|
||||
self.lock().iter().find(|s| s.id == id).cloned()
|
||||
}
|
||||
|
||||
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
|
||||
let mut servers = self.lock();
|
||||
let cloned = server.clone();
|
||||
servers.push(server);
|
||||
self.save(&servers)?;
|
||||
Ok(cloned)
|
||||
}
|
||||
|
||||
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
|
||||
let mut servers = self.lock();
|
||||
if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) {
|
||||
*s = updated.clone();
|
||||
self.save(&servers)?;
|
||||
Ok(updated)
|
||||
} else {
|
||||
Err(format!("MCP server {} not found", updated.id))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<(), String> {
|
||||
let mut servers = self.lock();
|
||||
let initial_len = servers.len();
|
||||
servers.retain(|s| s.id != id);
|
||||
if servers.len() == initial_len {
|
||||
return Err(format!("MCP server {} not found", id));
|
||||
}
|
||||
self.save(&servers)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
pub mod mcp_store;
|
||||
|
||||
pub use projects_store::*;
|
||||
pub use secure::*;
|
||||
pub use settings_store::*;
|
||||
pub use mcp_store::*;
|
||||
|
||||
@@ -70,17 +70,38 @@ impl ProjectsStore {
|
||||
(Vec::new(), false)
|
||||
};
|
||||
|
||||
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||
// operations can be in flight, so Starting/Stopping are always stale.
|
||||
let mut projects = projects;
|
||||
let mut needs_save = needs_save;
|
||||
for p in projects.iter_mut() {
|
||||
match p.status {
|
||||
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
|
||||
log::warn!(
|
||||
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
|
||||
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
|
||||
p.name,
|
||||
p.id
|
||||
);
|
||||
p.status = crate::models::ProjectStatus::Stopped;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
needs_save = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let store = Self {
|
||||
projects: Mutex::new(projects),
|
||||
file_path,
|
||||
};
|
||||
|
||||
// Persist migrated format back to disk
|
||||
// Persist migrated/reconciled format back to disk
|
||||
if needs_save {
|
||||
log::info!("Migrated projects.json from single-path to multi-path format");
|
||||
log::info!("Saving reconciled/migrated projects.json to disk");
|
||||
let projects = store.lock();
|
||||
if let Err(e) = store.save(&projects) {
|
||||
log::error!("Failed to save migrated projects: {}", e);
|
||||
log::error!("Failed to save projects: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ import TerminalView from "./components/terminal/TerminalView";
|
||||
import { useDocker } from "./hooks/useDocker";
|
||||
import { useSettings } from "./hooks/useSettings";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
import { useMcpServers } from "./hooks/useMcpServers";
|
||||
import { useUpdates } from "./hooks/useUpdates";
|
||||
import { useAppState } from "./store/appState";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage } = useDocker();
|
||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||
const { loadSettings } = useSettings();
|
||||
const { refresh } = useProjects();
|
||||
const { refresh: refreshMcp } = useMcpServers();
|
||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId } = useAppState(
|
||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||
@@ -22,10 +24,16 @@ export default function App() {
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
let stopPolling: (() => void) | undefined;
|
||||
checkDocker().then((available) => {
|
||||
if (available) checkImage();
|
||||
if (available) {
|
||||
checkImage();
|
||||
} else {
|
||||
stopPolling = startDockerPolling();
|
||||
}
|
||||
});
|
||||
refresh();
|
||||
refreshMcp();
|
||||
|
||||
// Update detection
|
||||
loadVersion();
|
||||
@@ -34,6 +42,7 @@ export default function App() {
|
||||
return () => {
|
||||
clearTimeout(updateTimer);
|
||||
cleanup?.();
|
||||
stopPolling?.();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock("../projects/ProjectList", () => ({
|
||||
vi.mock("../settings/SettingsPanel", () => ({
|
||||
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
|
||||
}));
|
||||
vi.mock("../mcp/McpPanel", () => ({
|
||||
default: () => <div data-testid="mcp-panel">McpPanel</div>,
|
||||
}));
|
||||
|
||||
describe("Sidebar", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import ProjectList from "../projects/ProjectList";
|
||||
import McpPanel from "../mcp/McpPanel";
|
||||
import SettingsPanel from "../settings/SettingsPanel";
|
||||
|
||||
export default function Sidebar() {
|
||||
@@ -8,35 +9,37 @@ export default function Sidebar() {
|
||||
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
||||
);
|
||||
|
||||
const tabCls = (view: typeof sidebarView) =>
|
||||
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||
sidebarView === view
|
||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 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)]"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||
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)]"
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
|
||||
MCP
|
||||
</button>
|
||||
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||
{sidebarView === "projects" ? (
|
||||
<ProjectList />
|
||||
) : sidebarView === "mcp" ? (
|
||||
<McpPanel />
|
||||
) : (
|
||||
<SettingsPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
76
app/src/components/mcp/McpPanel.tsx
Normal file
76
app/src/components/mcp/McpPanel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||
import McpServerCard from "./McpServerCard";
|
||||
|
||||
export default function McpPanel() {
|
||||
const { mcpServers, refresh, add, update, remove } = useMcpServers();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleAdd = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setError(null);
|
||||
try {
|
||||
await add(name);
|
||||
setNewName("");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)]">MCP Servers</h2>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
Define MCP servers globally, then enable them per-project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add new server */}
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
|
||||
placeholder="Server name..."
|
||||
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)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim()}
|
||||
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)]">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Server list */}
|
||||
<div className="space-y-2">
|
||||
{mcpServers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)] italic">
|
||||
No MCP servers configured.
|
||||
</p>
|
||||
) : (
|
||||
mcpServers.map((server) => (
|
||||
<McpServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
onUpdate={update}
|
||||
onRemove={remove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
app/src/components/mcp/McpServerCard.tsx
Normal file
262
app/src/components/mcp/McpServerCard.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { McpServer, McpTransportType } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
server: McpServer;
|
||||
onUpdate: (server: McpServer) => Promise<McpServer | void>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [name, setName] = useState(server.name);
|
||||
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
|
||||
const [command, setCommand] = useState(server.command ?? "");
|
||||
const [args, setArgs] = useState(server.args.join(" "));
|
||||
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
|
||||
const [url, setUrl] = useState(server.url ?? "");
|
||||
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
|
||||
|
||||
useEffect(() => {
|
||||
setName(server.name);
|
||||
setTransportType(server.transport_type);
|
||||
setCommand(server.command ?? "");
|
||||
setArgs(server.args.join(" "));
|
||||
setEnvPairs(Object.entries(server.env));
|
||||
setUrl(server.url ?? "");
|
||||
setHeaderPairs(Object.entries(server.headers));
|
||||
}, [server]);
|
||||
|
||||
const saveServer = async (patch: Partial<McpServer>) => {
|
||||
try {
|
||||
await onUpdate({ ...server, ...patch });
|
||||
} catch (err) {
|
||||
console.error("Failed to update MCP server:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
if (name !== server.name) saveServer({ name });
|
||||
};
|
||||
|
||||
const handleTransportChange = (t: McpTransportType) => {
|
||||
setTransportType(t);
|
||||
saveServer({ transport_type: t });
|
||||
};
|
||||
|
||||
const handleCommandBlur = () => {
|
||||
saveServer({ command: command || null });
|
||||
};
|
||||
|
||||
const handleArgsBlur = () => {
|
||||
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
|
||||
saveServer({ args: parsed });
|
||||
};
|
||||
|
||||
const handleUrlBlur = () => {
|
||||
saveServer({ url: url || null });
|
||||
};
|
||||
|
||||
const saveEnv = (pairs: [string, string][]) => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of pairs) {
|
||||
if (k.trim()) env[k.trim()] = v;
|
||||
}
|
||||
saveServer({ env });
|
||||
};
|
||||
|
||||
const saveHeaders = (pairs: [string, string][]) => {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of pairs) {
|
||||
if (k.trim()) headers[k.trim()] = v;
|
||||
}
|
||||
saveServer({ headers });
|
||||
};
|
||||
|
||||
const inputCls = "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)]";
|
||||
|
||||
const transportBadge = {
|
||||
stdio: "Stdio",
|
||||
http: "HTTP",
|
||||
sse: "SSE",
|
||||
}[transportType];
|
||||
|
||||
return (
|
||||
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||
>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
|
||||
<span className="text-sm font-medium truncate">{server.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
|
||||
{transportBadge}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
|
||||
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded config */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transport type */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["stdio", "http", "sse"] as McpTransportType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleTransportChange(t)}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
||||
transportType === t
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{t === "stdio" ? "Stdio" : t === "http" ? "HTTP" : "SSE"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stdio fields */}
|
||||
{transportType === "stdio" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
|
||||
<input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onBlur={handleCommandBlur}
|
||||
placeholder="npx"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
|
||||
<input
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
onBlur={handleArgsBlur}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<KeyValueEditor
|
||||
label="Environment Variables"
|
||||
pairs={envPairs}
|
||||
onChange={(pairs) => { setEnvPairs(pairs); }}
|
||||
onSave={saveEnv}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HTTP/SSE fields */}
|
||||
{(transportType === "http" || transportType === "sse") && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onBlur={handleUrlBlur}
|
||||
placeholder="http://localhost:3000/mcp"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<KeyValueEditor
|
||||
label="Headers"
|
||||
pairs={headerPairs}
|
||||
onChange={(pairs) => { setHeaderPairs(pairs); }}
|
||||
onSave={saveHeaders}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueEditor({
|
||||
label,
|
||||
pairs,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
pairs: [string, string][];
|
||||
onChange: (pairs: [string, string][]) => void;
|
||||
onSave: (pairs: [string, string][]) => void;
|
||||
}) {
|
||||
const inputCls = "flex-1 min-w-0 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)]";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
|
||||
{pairs.map(([key, value], i) => (
|
||||
<div key={i} className="flex gap-1 items-center mb-1">
|
||||
<input
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const updated = [...pairs] as [string, string][];
|
||||
updated[i] = [e.target.value, value];
|
||||
onChange(updated);
|
||||
}}
|
||||
onBlur={() => onSave(pairs)}
|
||||
placeholder="KEY"
|
||||
className={inputCls}
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-secondary)]">=</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const updated = [...pairs] as [string, string][];
|
||||
updated[i] = [key, e.target.value];
|
||||
onChange(updated);
|
||||
}}
|
||||
onBlur={() => onSave(pairs)}
|
||||
placeholder="value"
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = pairs.filter((_, j) => j !== i);
|
||||
onChange(updated);
|
||||
onSave(updated);
|
||||
}}
|
||||
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange([...pairs, ["", ""]]);
|
||||
}}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
109
app/src/components/projects/ContainerProgressModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
operation: "starting" | "stopping" | "resetting";
|
||||
progressMsg: string | null;
|
||||
error: string | null;
|
||||
completed: boolean;
|
||||
onForceStop: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const operationLabels: Record<string, string> = {
|
||||
starting: "Starting",
|
||||
stopping: "Stopping",
|
||||
resetting: "Resetting",
|
||||
};
|
||||
|
||||
export default function ContainerProgressModal({
|
||||
projectName,
|
||||
operation,
|
||||
progressMsg,
|
||||
error,
|
||||
completed,
|
||||
onForceStop,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-close on success after 800ms
|
||||
useEffect(() => {
|
||||
if (completed && !error) {
|
||||
const timer = setTimeout(onClose, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
// Escape to close (only when completed or error)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && (completed || error)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, error, onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
||||
},
|
||||
[completed, error, onClose],
|
||||
);
|
||||
|
||||
const inProgress = !completed && !error;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
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-80 shadow-xl text-center">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
{operationLabels[operation]} “{projectName}”
|
||||
</h3>
|
||||
|
||||
{/* Spinner / checkmark / error icon */}
|
||||
<div className="flex justify-center mb-3">
|
||||
{error ? (
|
||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
||||
) : completed ? (
|
||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
||||
) : (
|
||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress message */}
|
||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
||||
{error
|
||||
? <span className="text-[var(--error)]">{error}</span>
|
||||
: completed
|
||||
? "Done!"
|
||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
||||
</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{inProgress && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
||||
>
|
||||
Force Stop
|
||||
</button>
|
||||
)}
|
||||
{(completed || error) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,16 @@ vi.mock("../../hooks/useTerminal", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMcpServers", () => ({
|
||||
useMcpServers: () => ({
|
||||
mcpServers: [],
|
||||
refresh: vi.fn(),
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockSelectedProjectId: string | null = null;
|
||||
vi.mock("../../store/appState", () => ({
|
||||
useAppState: vi.fn((selector) =>
|
||||
@@ -55,7 +65,9 @@ const mockProject: Project = {
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
enabled_mcp_servers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -16,6 +19,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
||||
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
||||
const { start, stop, rebuild, remove, update } = useProjects();
|
||||
const { mcpServers } = useMcpServers();
|
||||
const { open: openTerminal } = useTerminal();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -23,6 +27,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -64,9 +71,38 @@ export default function ProjectCard({ project }: Props) {
|
||||
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
// Listen for container progress events
|
||||
useEffect(() => {
|
||||
const unlisten = listen<{ project_id: string; message: string }>(
|
||||
"container-progress",
|
||||
(event) => {
|
||||
if (event.payload.project_id === project.id) {
|
||||
setProgressMsg(event.payload.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => { unlisten.then((f) => f()); };
|
||||
}, [project.id]);
|
||||
|
||||
// Mark operation completed when status settles
|
||||
useEffect(() => {
|
||||
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
||||
if (activeOperation) {
|
||||
setOperationCompleted(true);
|
||||
}
|
||||
// Clear progress if no modal is managing it
|
||||
if (!activeOperation) {
|
||||
setProgressMsg(null);
|
||||
}
|
||||
}
|
||||
}, [project.status, activeOperation]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("starting");
|
||||
try {
|
||||
await start(project.id);
|
||||
} catch (e) {
|
||||
@@ -79,6 +115,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
const handleStop = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("stopping");
|
||||
try {
|
||||
await stop(project.id);
|
||||
} catch (e) {
|
||||
@@ -96,6 +135,21 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceStop = async () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveOperation(null);
|
||||
setOperationCompleted(false);
|
||||
setProgressMsg(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const defaultBedrockConfig: BedrockConfig = {
|
||||
auth_method: "static_credentials",
|
||||
aws_region: "us-east-1",
|
||||
@@ -302,6 +356,10 @@ export default function ProjectCard({ project }: Props) {
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgressMsg(null);
|
||||
setOperationCompleted(false);
|
||||
setActiveOperation("resetting");
|
||||
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}}
|
||||
@@ -315,9 +373,12 @@ export default function ProjectCard({ project }: Props) {
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{project.status}...
|
||||
</span>
|
||||
<>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{progressMsg ?? `${project.status}...`}
|
||||
</span>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
|
||||
</>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
||||
@@ -554,6 +615,40 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MCP Servers */}
|
||||
{mcpServers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((server) => {
|
||||
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||
return (
|
||||
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!isStopped}
|
||||
onChange={async () => {
|
||||
const updated = enabled
|
||||
? project.enabled_mcp_servers.filter((id) => id !== server.id)
|
||||
: [...project.enabled_mcp_servers, server.id];
|
||||
try {
|
||||
await update({ ...project, enabled_mcp_servers: updated });
|
||||
} catch (err) {
|
||||
console.error("Failed to update MCP servers:", err);
|
||||
}
|
||||
}}
|
||||
className="rounded border-[var(--border-color)] disabled:opacity-50"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bedrock config */}
|
||||
{project.auth_mode === "bedrock" && (() => {
|
||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||
@@ -722,6 +817,18 @@ export default function ProjectCard({ project }: Props) {
|
||||
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeOperation && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
operation={activeOperation}
|
||||
progressMsg={progressMsg}
|
||||
error={error}
|
||||
completed={operationCompleted}
|
||||
onForceStop={handleForceStop}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -86,6 +87,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Track scroll position to show "Jump to Current" button
|
||||
const scrollDisposable = term.onScroll(() => {
|
||||
const buf = term.buffer.active;
|
||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
||||
});
|
||||
|
||||
// Handle image paste: intercept paste events with image data,
|
||||
// upload to the container, and inject the file path into terminal input.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
@@ -164,6 +171,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
@@ -231,6 +239,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, [detectedUrl]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
termRef.current?.scrollToBottom();
|
||||
setIsAtBottom(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalContainerRef}
|
||||
@@ -251,6 +264,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
||||
>
|
||||
Jump to Current ↓
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
@@ -59,6 +59,39 @@ export function useDocker() {
|
||||
[setImageExists],
|
||||
);
|
||||
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startDockerPolling = useCallback(() => {
|
||||
// Don't start if already polling
|
||||
if (pollingRef.current) return () => {};
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const available = await commands.checkDocker();
|
||||
if (available) {
|
||||
clearInterval(interval);
|
||||
pollingRef.current = null;
|
||||
setDockerAvailable(true);
|
||||
// Also check image once Docker is available
|
||||
try {
|
||||
const exists = await commands.checkImageExists();
|
||||
setImageExists(exists);
|
||||
} catch {
|
||||
setImageExists(false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Still not available, keep polling
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
pollingRef.current = interval;
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
pollingRef.current = null;
|
||||
};
|
||||
}, [setDockerAvailable, setImageExists]);
|
||||
|
||||
const pullImage = useCallback(
|
||||
async (imageName: string, onProgress?: (msg: string) => void) => {
|
||||
const unlisten = onProgress
|
||||
@@ -84,5 +117,6 @@ export function useDocker() {
|
||||
checkImage,
|
||||
buildImage,
|
||||
pullImage,
|
||||
startDockerPolling,
|
||||
};
|
||||
}
|
||||
|
||||
55
app/src/hooks/useMcpServers.ts
Normal file
55
app/src/hooks/useMcpServers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type { McpServer } from "../lib/types";
|
||||
|
||||
export function useMcpServers() {
|
||||
const {
|
||||
mcpServers,
|
||||
setMcpServers,
|
||||
updateMcpServerInList,
|
||||
removeMcpServerFromList,
|
||||
} = useAppState(
|
||||
useShallow(s => ({
|
||||
mcpServers: s.mcpServers,
|
||||
setMcpServers: s.setMcpServers,
|
||||
updateMcpServerInList: s.updateMcpServerInList,
|
||||
removeMcpServerFromList: s.removeMcpServerFromList,
|
||||
}))
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const list = await commands.listMcpServers();
|
||||
setMcpServers(list);
|
||||
}, [setMcpServers]);
|
||||
|
||||
const add = useCallback(
|
||||
async (name: string) => {
|
||||
const server = await commands.addMcpServer(name);
|
||||
const list = await commands.listMcpServers();
|
||||
setMcpServers(list);
|
||||
return server;
|
||||
},
|
||||
[setMcpServers],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (server: McpServer) => {
|
||||
const updated = await commands.updateMcpServer(server);
|
||||
updateMcpServerInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateMcpServerInList],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.removeMcpServer(id);
|
||||
removeMcpServerFromList(id);
|
||||
},
|
||||
[removeMcpServerFromList],
|
||||
);
|
||||
|
||||
return { mcpServers, refresh, add, update, remove };
|
||||
}
|
||||
@@ -50,31 +50,45 @@ export function useProjects() {
|
||||
[removeProjectFromList],
|
||||
);
|
||||
|
||||
const setOptimisticStatus = useCallback(
|
||||
(id: string, status: "starting" | "stopping") => {
|
||||
const { projects } = useAppState.getState();
|
||||
const project = projects.find((p) => p.id === id);
|
||||
if (project) {
|
||||
updateProjectInList({ ...project, status });
|
||||
}
|
||||
},
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "stopping");
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
},
|
||||
[setProjects],
|
||||
[setProjects, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList],
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -50,6 +50,15 @@ export const closeTerminalSession = (sessionId: string) =>
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
|
||||
// MCP Servers
|
||||
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||
export const addMcpServer = (name: string) =>
|
||||
invoke<McpServer>("add_mcp_server", { name });
|
||||
export const updateMcpServer = (server: McpServer) =>
|
||||
invoke<McpServer>("update_mcp_server", { server });
|
||||
export const removeMcpServer = (serverId: string) =>
|
||||
invoke<void>("remove_mcp_server", { serverId });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
export const checkForUpdates = () =>
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface Project {
|
||||
custom_env_vars: EnvVar[];
|
||||
port_mappings: PortMapping[];
|
||||
claude_instructions: string | null;
|
||||
enabled_mcp_servers: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -115,3 +116,18 @@ export interface ReleaseAsset {
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type McpTransportType = "stdio" | "http" | "sse";
|
||||
|
||||
export interface McpServer {
|
||||
id: string;
|
||||
name: string;
|
||||
transport_type: McpTransportType;
|
||||
command: string | null;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
url: string | null;
|
||||
headers: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
|
||||
|
||||
interface AppState {
|
||||
// Projects
|
||||
@@ -17,9 +17,15 @@ interface AppState {
|
||||
removeSession: (id: string) => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
|
||||
// MCP servers
|
||||
mcpServers: McpServer[];
|
||||
setMcpServers: (servers: McpServer[]) => void;
|
||||
updateMcpServerInList: (server: McpServer) => void;
|
||||
removeMcpServerFromList: (id: string) => void;
|
||||
|
||||
// UI state
|
||||
sidebarView: "projects" | "settings";
|
||||
setSidebarView: (view: "projects" | "settings") => void;
|
||||
sidebarView: "projects" | "mcp" | "settings";
|
||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||
dockerAvailable: boolean | null;
|
||||
setDockerAvailable: (available: boolean | null) => void;
|
||||
imageExists: boolean | null;
|
||||
@@ -75,6 +81,20 @@ export const useAppState = create<AppState>((set) => ({
|
||||
}),
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
setMcpServers: (servers) => set({ mcpServers: servers }),
|
||||
updateMcpServerInList: (server) =>
|
||||
set((state) => ({
|
||||
mcpServers: state.mcpServers.map((s) =>
|
||||
s.id === server.id ? server : s,
|
||||
),
|
||||
})),
|
||||
removeMcpServerFromList: (id) =>
|
||||
set((state) => ({
|
||||
mcpServers: state.mcpServers.filter((s) => s.id !== id),
|
||||
})),
|
||||
|
||||
// UI state
|
||||
sidebarView: "projects",
|
||||
setSidebarView: (view) => set({ sidebarView: view }),
|
||||
|
||||
@@ -103,6 +103,27 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||
unset CLAUDE_INSTRUCTIONS
|
||||
fi
|
||||
|
||||
# ── MCP server configuration ────────────────────────────────────────────────
|
||||
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||
# OAuth tokens). Creates the file if it doesn't exist.
|
||||
if [ -n "$MCP_SERVERS_JSON" ]; then
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
if [ -f "$CLAUDE_JSON" ]; then
|
||||
# Merge: existing config + MCP config (MCP keys override on conflict)
|
||||
MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
else
|
||||
echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON"
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON"
|
||||
fi
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
unset MCP_SERVERS_JSON
|
||||
fi
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
|
||||
Reference in New Issue
Block a user