Compare commits

...

18 Commits

Author SHA1 Message Date
4721950eae Set COLORTERM=truecolor in container environment
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m23s
Tells CLI tools (Claude Code, vim, etc.) that the xterm.js terminal
supports 24-bit RGB color so they use the full palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:30:38 +00:00
fba4b9442c Update app icons, fix sidebar path overflow, and remove terminal URL accumulator
Some checks failed
Build App / build-windows (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
Replace placeholder icons with the Triple-C branded logo at all required
Tauri sizes. Remove the host_path display from sidebar folder listings to
prevent text overflow. Remove the URL accumulator that injected clickable
login URL text into the terminal — the native WebLinksAddon still handles
URLs when the window is wide enough. Add explicit logging on container
removal confirming named volumes are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:11 +00:00
48f0e2f64c Fix terminal switching lag by managing WebGL contexts dynamically
All checks were successful
Build App / build-linux (push) Successful in 2m32s
Build App / build-windows (push) Successful in 3m32s
Only the active terminal holds a WebGL rendering context now. When
switching tabs the outgoing terminal disposes its WebGL addon (freeing
the GPU context) and the incoming terminal creates a fresh one. This
avoids exhausting the browser's limited WebGL context pool (~8-16) which
caused expensive context loss/restoration lag when switching.

Also skip ResizeObserver callbacks for hidden terminals (zero dimensions)
to avoid unnecessary fit/resize work on inactive tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:22:54 +00:00
7e1cc92aa4 Add app update detection and multi-folder project support
All checks were successful
Build App / build-linux (push) Successful in 2m54s
Build App / build-windows (push) Successful in 4m18s
Build Container / build-container (push) Successful in 1m30s
Feature 1 - Update Detection: Query Gitea releases API on startup (3s
delay) and every 24h, compare patch versions by platform, show pulsing
"Update" button in TopBar with dialog for release notes/downloads.
Settings: auto-check toggle, manual check, dismiss per-version.

Feature 2 - Multi-Folder Projects: Replace single `path` with
`paths: Vec<ProjectPath>` (host_path + mount_name). Each folder mounts
to `/workspace/{mount_name}`. Auto-migrate old single-path JSON on load.
Container recreation via paths-fingerprint label. AddProjectDialog and
ProjectCard support add/remove/edit of multiple folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:18:33 +00:00
854f59a95a Fix Docker/CI: reproducible Windows build, Dockerfile cleanup
- Fix Windows CI build to use npm ci instead of deleting lockfile and
  running npm install, ensuring reproducible cross-platform builds
- Remove duplicate uv/ruff root installations from Dockerfile (only
  need the claude user installations)
- Make AWS CLI install architecture-aware using uname -m for arm64
  compatibility
- Remove unused SiblingContainers component (dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:43:14 +00:00
265b365f0b Fix security: enable CSP and eliminate shell injection in entrypoint
- Enable restrictive Content Security Policy in tauri.conf.json instead
  of null (disabled), restricting scripts/connects to self + Tauri IPC
- Fix shell injection in entrypoint.sh by replacing su -c with direct
  git config --file writes, preventing names with quotes (e.g. O'Brien)
  from breaking startup or enabling code execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:43:04 +00:00
03e0590631 Fix Rust backend: secrets to keychain, status recovery, shutdown, dedup
- Move git_token and Bedrock credentials to OS keychain instead of
  storing in plaintext projects.json via skip_serializing + keyring
- Fix project status stuck in Starting on container creation failure
  by resetting to Stopped on any error path
- Add granular store methods to reduce TOCTOU race window
- Add auth_mode, project path, and bedrock config change detection
  to container_needs_recreation with label-based fingerprinting
- Fix mutex held across async Docker API call in exec resize by
  cloning exec_id under lock then releasing before API call
- Add graceful shutdown via on_window_event to clean up exec sessions
- Extract compute_env_fingerprint and merge_claude_instructions helpers
  to eliminate code duplication in container.rs
- Remove unused thiserror dependency
- Return error instead of falling back to CWD when data dir unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:55 +00:00
82f159d2a9 Fix frontend UX: debounce saves, Zustand selectors, init race, dialog
- Debounce project config saves: use local state + save-on-blur instead
  of firing IPC requests on every keystroke in text inputs
- Add Zustand selectors to all store consumers to prevent full-store
  re-renders on any state change
- Fix initialization race: chain checkImage after checkDocker resolves
- Fix DockerSettings setTimeout race: await checkImage after save
- Add console.error logging to all 11 empty catch blocks in ProjectCard
- Add keyboard support to AddProjectDialog: Escape to close,
  click-outside-to-close, form submit on Enter, auto-focus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:40 +00:00
a03bdccdc7 Fix TerminalView: URL detection, event listener leak, resize throttle
- Fix broken URL accumulator by using TextDecoder instead of raw
  Uint8Array concatenation that produced numeric strings
- Fix event listener memory leak by using aborted flag pattern to
  ensure cleanup runs even if listen() promises haven't resolved
- Throttle ResizeObserver with requestAnimationFrame to prevent
  hammering the backend during window resize

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:13 +00:00
82c487184a Add custom env vars and Claude instructions for projects
All checks were successful
Build App / build-windows (push) Successful in 3m24s
Build App / build-linux (push) Successful in 5m36s
Build Container / build-container (push) Successful in 56s
Support per-project environment variables injected into containers,
plus global and per-project Claude Code instructions written to
~/.claude/CLAUDE.md inside the container on start. Reserved env var
prefixes are blocked, and changes trigger automatic container recreation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:39:20 -08:00
96f8acc40d Fix Docker socket mount failing on Windows
All checks were successful
Build App / build-linux (push) Successful in 3m24s
Build App / build-windows (push) Successful in 3m51s
The Windows named pipe (//./pipe/docker_engine) cannot be bind-mounted
into a Linux container. Use /var/run/docker.sock as the mount source
on Windows, which Docker Desktop exposes for container mounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:49:00 -08:00
b77b9679b1 Auto-increment app version using git commit count in CI builds
All checks were successful
Build App / build-windows (push) Successful in 3m11s
Build App / build-linux (push) Successful in 4m7s
Version is computed as 0.1.{commit_count} and patched into
tauri.conf.json, package.json, and Cargo.toml at build time.
Release tags now use v0.1.N format instead of build-{sha}.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:12:24 -08:00
0a4f207556 Fix stopping one project killing all project terminal sessions
All checks were successful
Build App / build-windows (push) Successful in 3m11s
Build App / build-linux (push) Successful in 6m15s
close_all_sessions() was called when stopping/removing/rebuilding a
project, which shut down exec sessions for every project. Track
container_id per session and use close_sessions_for_container() to
only close sessions belonging to the target project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:55:38 +00:00
839dd9f105 Update project documentation with architecture and recent changes
Expand Triple-C.md from a one-liner to comprehensive docs covering
architecture, container lifecycle, mounts, auth modes, sibling
containers, Docker socket handling, key files, and CSS/styling notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:50:41 -08:00
df3d434877 Fix SSH keys, git config, and HTTPS token not applied on container restart
All checks were successful
Build App / build-linux (push) Successful in 2m26s
Build App / build-windows (push) Successful in 3m17s
Recreate the container when SSH key path, git name, git email, or git
HTTPS token change — not just when the docker socket toggle changes.
The claude config named volume persists across recreation so no data
is lost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:37:06 +00:00
60842befde Fix UI padding and text flush against container edges
All checks were successful
Build App / build-windows (push) Successful in 3m16s
Build App / build-linux (push) Successful in 4m16s
- Remove global * { padding: 0 } reset that was overriding all Tailwind
  padding classes (unlayered CSS beats Tailwind v4 @layer utilities)
- Add color-scheme: dark to fix native form controls (select dropdowns)
  rendering with white backgrounds
- Make sidebar responsive (25% width, min 224px, max 320px)
- Increase internal padding on TopBar, Sidebar, ProjectList, StatusBar
- Add flex-shrink-0 to TopBar status indicators to prevent clipping
- Allow project action buttons to wrap on narrow sidebars
- Increase terminal view padding for breathing room

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:31:27 -08:00
1a78378ed7 Fix docker socket not mounting when toggling container spawning
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m10s
When "Allow container spawning" was toggled on an existing container,
the docker socket mount was never applied because the container was
simply restarted rather than recreated. Now inspects the existing
container's mounts and recreates it when there's a mismatch, preserving
the named config volume (keyed by project ID) across recreation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:56:39 -08:00
0d4ed86f53 adding claude settings 2026-02-27 09:40:19 -08:00
48 changed files with 2292 additions and 374 deletions

View File

@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(.:*)" "Bash(.:*)",
"Bash(git:*)"
] ]
} }
} }

View File

@@ -22,6 +22,24 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${VERSION}"
- name: Install system dependencies - name: Install system dependencies
run: | run: |
@@ -80,12 +98,12 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="build-$(echo ${{ gitea.sha }} | cut -c1-7)" TAG="v${{ steps.version.outputs.VERSION }}"
# Create release # Create release
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Linux Build ${TAG}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
@@ -109,6 +127,25 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.1.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version
shell: powershell
run: |
$version = "${{ steps.version.outputs.VERSION }}"
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install Rust stable - name: Install Rust stable
run: | run: |
@@ -155,8 +192,7 @@ jobs:
run: | run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
if exist node_modules rmdir /s /q node_modules if exist node_modules rmdir /s /q node_modules
if exist package-lock.json del package-lock.json npm ci
npm install
- name: Build frontend - name: Build frontend
working-directory: ./app working-directory: ./app
@@ -186,9 +222,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
set "TAG=build-win-%COMMIT_SHA:~0,7%" set "TAG=v${{ steps.version.outputs.VERSION }}-win"
echo Creating release %TAG%... echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Windows Build %TAG%\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found :found
echo Release ID: %RELEASE_ID% echo Release ID: %RELEASE_ID%

View File

@@ -1,3 +1,104 @@
# Triple-C (Claude-Code-Container) # 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. Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
## Architecture
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
- **Backend**: Rust (Tauri v2 framework)
- **Terminal**: xterm.js with WebGL rendering
- **Docker API**: bollard (pure Rust Docker client)
### Layout Structure
```
┌─────────────────────────────────────────────────────┐
│ TopBar (terminal tabs + Docker/Image status) │
├────────────┬────────────────────────────────────────┤
│ Sidebar │ Main Content (terminal views) │
│ (25% w, │ │
│ responsive│ │
│ min/max) │ │
├────────────┴────────────────────────────────────────┤
│ StatusBar (project/terminal counts) │
└─────────────────────────────────────────────────────┘
```
### Container Lifecycle
1. **Create**: New container created with bind mounts, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
3. **Terminal**: `docker exec` launches Claude Code with a PTY
4. **Stop**: Container halted (filesystem persists in named volume)
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
### Mounts
| Target in Container | Source | Type | Notes |
|---|---|---|---|
| `/workspace` | Project directory | Bind | Read-write |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
### Authentication Modes
Each project can independently use one of:
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
### Container Spawning (Sibling Containers)
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
### Docker Socket Path
The socket path is OS-aware:
- **Linux/macOS**: `/var/run/docker.sock`
- **Windows**: `//./pipe/docker_engine`
Users can override this in Settings via the global `docker_socket_path` option.
## Key Files
| File | Purpose |
|---|---|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
## CSS / Styling Notes
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
- All colors use CSS custom properties defined in `index.css` `:root`
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
## Container Image
**Base**: Ubuntu 24.04
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)

279
app/src-tauri/Cargo.lock generated
View File

@@ -523,6 +523,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -1333,8 +1339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1344,9 +1352,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1649,6 +1659,23 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -2153,6 +2180,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@@ -2985,6 +3018,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -3025,6 +3113,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@@ -3045,6 +3143,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@@ -3063,6 +3171,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@@ -3165,6 +3282,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.2"
@@ -3223,6 +3378,26 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -3245,6 +3420,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3709,6 +3919,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3873,7 +4089,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4258,6 +4474,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -4286,6 +4517,16 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -4504,6 +4745,7 @@ dependencies = [
"futures-util", "futures-util",
"keyring", "keyring",
"log", "log",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tar", "tar",
@@ -4512,7 +4754,6 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-store", "tauri-plugin-store",
"thiserror 2.0.18",
"tokio", "tokio",
"uuid", "uuid",
] ]
@@ -4605,6 +4846,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4857,6 +5104,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.2" version = "2.0.2"
@@ -4901,6 +5158,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -5131,6 +5397,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"

View File

@@ -24,11 +24,11 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3" futures-util = "0.3"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
dirs = "6" dirs = "6"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
tar = "0.4" tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -2,3 +2,4 @@ pub mod docker_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;
pub mod terminal_commands; pub mod terminal_commands;
pub mod update_commands;

View File

@@ -1,10 +1,48 @@
use tauri::State; use tauri::State;
use crate::docker; use crate::docker;
use crate::models::{container_config, AuthMode, Project, ProjectStatus}; use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
/// 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 {
secure::store_project_secret(&project.id, "git-token", token)?;
}
if let Some(ref bedrock) = project.bedrock_config {
if let Some(ref v) = bedrock.aws_access_key_id {
secure::store_project_secret(&project.id, "aws-access-key-id", v)?;
}
if let Some(ref v) = bedrock.aws_secret_access_key {
secure::store_project_secret(&project.id, "aws-secret-access-key", v)?;
}
if let Some(ref v) = bedrock.aws_session_token {
secure::store_project_secret(&project.id, "aws-session-token", v)?;
}
if let Some(ref v) = bedrock.aws_bearer_token {
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
}
}
Ok(())
}
/// Populate secret fields on a project struct from the OS keychain.
fn load_secrets_for_project(project: &mut Project) {
project.git_token = secure::get_project_secret(&project.id, "git-token")
.unwrap_or(None);
if let Some(ref mut bedrock) = project.bedrock_config {
bedrock.aws_access_key_id = secure::get_project_secret(&project.id, "aws-access-key-id")
.unwrap_or(None);
bedrock.aws_secret_access_key = secure::get_project_secret(&project.id, "aws-secret-access-key")
.unwrap_or(None);
bedrock.aws_session_token = secure::get_project_secret(&project.id, "aws-session-token")
.unwrap_or(None);
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
.unwrap_or(None);
}
}
#[tauri::command] #[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> { pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list()) Ok(state.projects_store.list())
@@ -13,10 +51,27 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
#[tauri::command] #[tauri::command]
pub async fn add_project( pub async fn add_project(
name: String, name: String,
path: String, paths: Vec<ProjectPath>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
let project = Project::new(name, path); // Validate paths
if paths.is_empty() {
return Err("At least one folder path is required.".to_string());
}
let mut seen_names = std::collections::HashSet::new();
for p in &paths {
if p.mount_name.is_empty() {
return Err("Mount name cannot be empty.".to_string());
}
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
}
if !seen_names.insert(p.mount_name.clone()) {
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
}
}
let project = Project::new(name, paths);
store_secrets_for_project(&project)?;
state.projects_store.add(project) state.projects_store.add(project)
} }
@@ -28,13 +83,16 @@ pub async fn remove_project(
// Stop and remove container if it exists // Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) { if let Some(project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_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::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await; let _ = docker::remove_container(container_id).await;
} }
} }
// Close any exec sessions // Clean up keychain secrets for this project
state.exec_manager.close_all_sessions().await; if let Err(e) = secure::delete_project_secrets(&project_id) {
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
}
state.projects_store.remove(&project_id) state.projects_store.remove(&project_id)
} }
@@ -44,6 +102,7 @@ pub async fn update_project(
project: Project, project: Project,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Project, String> { ) -> Result<Project, String> {
store_secrets_for_project(&project)?;
state.projects_store.update(project) state.projects_store.update(project)
} }
@@ -57,6 +116,10 @@ pub async fn start_project_container(
.get(&project_id) .get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?; .ok_or_else(|| format!("Project {} not found", project_id))?;
// Populate secret fields from the OS keychain so they are available
// in memory when building environment variables for the container.
load_secrets_for_project(&mut project);
// Load settings for image resolution and global AWS // Load settings for image resolution and global AWS
let settings = state.settings_store.get(); let settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
@@ -85,41 +148,76 @@ pub async fn start_project_container(
// Update status to starting // Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?; state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
// Ensure image exists // Wrap container operations so that any failure resets status to Stopped.
if !docker::image_exists(&image_name).await? { let result: Result<String, String> = async {
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; // Ensure image exists
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); if !docker::image_exists(&image_name).await? {
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
}
// Determine docker socket path
let docker_socket = settings.docker_socket_path
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| default_docker_socket());
// 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(),
)
.await
.unwrap_or(false);
if needs_recreation {
log::info!("Container config changed, recreating container for project {}", project.id);
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
).await?;
docker::start_container(&new_id).await?;
new_id
} else {
docker::start_container(&existing_id).await?;
existing_id
}
} else {
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
).await?;
docker::start_container(&new_id).await?;
new_id
};
Ok(container_id)
}.await;
// On failure, reset status to Stopped so the project doesn't get stuck.
if let Err(ref e) = result {
log::error!("Failed to start container for project {}: {}", project_id, e);
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
} }
// Determine docker socket path let container_id = result?;
let docker_socket = settings.docker_socket_path
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| default_docker_socket());
// AWS config path from global settings // Update project with container info using granular methods (Issue 14: TOCTOU)
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? {
// 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,
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
).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.set_container_id(&project_id, Some(container_id.clone()))?;
state.projects_store.update_status(&project_id, ProjectStatus::Running)?; state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
@@ -142,7 +240,7 @@ pub async fn stop_project_container(
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// Close exec sessions for this project // Close exec sessions for this project
state.exec_manager.close_all_sessions().await; state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?; docker::stop_container(container_id).await?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
@@ -163,7 +261,7 @@ pub async fn rebuild_project_container(
// Remove existing container // Remove existing container
if let Some(ref container_id) = project.container_id { if let Some(ref container_id) = project.container_id {
state.exec_manager.close_all_sessions().await; state.exec_manager.close_sessions_for_container(container_id).await;
let _ = docker::stop_container(container_id).await; let _ = docker::stop_container(container_id).await;
docker::remove_container(container_id).await?; docker::remove_container(container_id).await?;
state.projects_store.set_container_id(&project_id, None)?; state.projects_store.set_container_id(&project_id, None)?;

View File

@@ -0,0 +1,117 @@
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[tauri::command]
pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client
.get(RELEASES_URL)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let is_windows = cfg!(target_os = "windows");
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
.iter()
.filter(|r| {
if is_windows {
r.tag_name.ends_with("-win")
} else {
!r.tag_name.ends_with("-win")
}
})
.collect();
// Find the latest release with a higher patch version
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
let current_patch = parse_patch_version(current_version).unwrap_or(0);
let mut best: Option<(&GiteaRelease, u32)> = None;
for release in &platform_releases {
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
if patch > current_patch {
if best.is_none() || patch > best.unwrap().1 {
best = Some((release, patch));
}
}
}
}
match best {
Some((release, _)) => {
let assets = release
.assets
.iter()
.map(|a| ReleaseAsset {
name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(),
size: a.size,
})
.collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone());
Ok(Some(UpdateInfo {
version,
tag_name: release.tag_name.clone(),
release_url: release.html_url.clone(),
body: release.body.clone(),
assets,
published_at: release.published_at.clone(),
}))
}
None => Ok(None),
}
}
/// Parse patch version from a semver string like "0.1.5" -> 5
fn parse_patch_version(version: &str) -> Option<u32> {
let clean = version.trim_start_matches('v');
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 {
parts[2].parse().ok()
} else {
None
}
}
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win").unwrap_or(clean);
parse_patch_version(clean)
}
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let clean = tag.trim_start_matches('v');
let clean = clean.strip_suffix("-win").unwrap_or(clean);
// Validate it looks like a version
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
Some(clean.to_string())
} else {
None
}
}

View File

@@ -4,9 +4,77 @@ use bollard::container::{
}; };
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum}; use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use super::client::get_docker; use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project}; use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
/// 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 {
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
if is_reserved {
continue;
}
parts.push(format!("{}={}", key, env_var.value));
}
parts.sort();
parts.join(",")
}
/// Merge global and per-project Claude instructions into a single string.
fn merge_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
) -> Option<String> {
match (global_instructions, project_instructions) {
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
(Some(g), None) => Some(g.to_string()),
(None, Some(p)) => Some(p.to_string()),
(None, None) => None,
}
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let mut hasher = DefaultHasher::new();
format!("{:?}", bedrock.auth_method).hash(&mut hasher);
bedrock.aws_region.hash(&mut hasher);
bedrock.aws_access_key_id.hash(&mut hasher);
bedrock.aws_secret_access_key.hash(&mut hasher);
bedrock.aws_session_token.hash(&mut hasher);
bedrock.aws_profile.hash(&mut hasher);
bedrock.aws_bearer_token.hash(&mut hasher);
bedrock.model_id.hash(&mut hasher);
bedrock.disable_prompt_caching.hash(&mut hasher);
format!("{:x}", hasher.finish())
} else {
String::new()
}
}
/// Compute a fingerprint for the project paths so we can detect changes.
/// Sorted by mount_name so order changes don't cause spurious recreation.
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
let mut parts: Vec<String> = paths
.iter()
.map(|p| format!("{}:{}", p.mount_name, p.host_path))
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> { pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?; let docker = get_docker()?;
@@ -45,12 +113,16 @@ pub async fn create_container(
image_name: &str, image_name: &str,
aws_config_path: Option<&str>, aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings, global_aws: &GlobalAwsSettings,
global_claude_instructions: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
let mut env_vars: Vec<String> = Vec::new(); let mut env_vars: Vec<String> = Vec::new();
// Tell CLI tools the terminal supports 24-bit RGB color
env_vars.push("COLORTERM=truecolor".to_string());
// Pass host UID/GID so the entrypoint can remap the container user // Pass host UID/GID so the entrypoint can remap the container user
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -150,24 +222,53 @@ pub async fn create_container(
} }
} }
let mut mounts = vec![ // Custom environment variables
// Project directory -> /workspace let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
Mount { for env_var in &project.custom_env_vars {
target: Some("/workspace".to_string()), let key = env_var.key.trim();
source: Some(project.path.clone()), if key.is_empty() {
continue;
}
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
if is_reserved {
log::warn!("Skipping reserved env var: {}", key);
continue;
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Claude instructions (global + per-project)
let combined_instructions = merge_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
);
if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name}
for pp in &project.paths {
mounts.push(Mount {
target: Some(format!("/workspace/{}", pp.mount_name)),
source: Some(pp.host_path.clone()),
typ: Some(MountTypeEnum::BIND), typ: Some(MountTypeEnum::BIND),
read_only: Some(false), read_only: Some(false),
..Default::default() ..Default::default()
}, });
// Named volume for claude config persistence }
Mount {
target: Some("/home/claude/.claude".to_string()), // Named volume for claude config persistence
source: Some(format!("triple-c-claude-config-{}", project.id)), mounts.push(Mount {
typ: Some(MountTypeEnum::VOLUME), target: Some("/home/claude/.claude".to_string()),
read_only: Some(false), source: Some(format!("triple-c-claude-config-{}", project.id)),
..Default::default() typ: Some(MountTypeEnum::VOLUME),
}, read_only: Some(false),
]; ..Default::default()
});
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms) // SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
if let Some(ref ssh_path) = project.ssh_key_path { if let Some(ref ssh_path) = project.ssh_key_path {
@@ -212,9 +313,17 @@ pub async fn create_container(
// Docker socket (only if allowed) // Docker socket (only if allowed)
if project.allow_docker_access { if project.allow_docker_access {
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts.
let mount_source = if docker_socket_path == "//./pipe/docker_engine" {
"/var/run/docker.sock".to_string()
} else {
docker_socket_path.to_string()
};
mounts.push(Mount { mounts.push(Mount {
target: Some("/var/run/docker.sock".to_string()), target: Some("/var/run/docker.sock".to_string()),
source: Some(docker_socket_path.to_string()), source: Some(mount_source),
typ: Some(MountTypeEnum::BIND), typ: Some(MountTypeEnum::BIND),
read_only: Some(false), read_only: Some(false),
..Default::default() ..Default::default()
@@ -225,18 +334,28 @@ pub async fn create_container(
labels.insert("triple-c.managed".to_string(), "true".to_string()); 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-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.image".to_string(), image_name.to_string());
let host_config = HostConfig { let host_config = HostConfig {
mounts: Some(mounts), mounts: Some(mounts),
..Default::default() ..Default::default()
}; };
let working_dir = if project.paths.len() == 1 {
format!("/workspace/{}", project.paths[0].mount_name)
} else {
"/workspace".to_string()
};
let config = Config { let config = Config {
image: Some(image_name.to_string()), image: Some(image_name.to_string()),
hostname: Some("triple-c".to_string()), hostname: Some("triple-c".to_string()),
env: Some(env_vars), env: Some(env_vars),
labels: Some(labels), labels: Some(labels),
working_dir: Some("/workspace".to_string()), working_dir: Some(working_dir),
host_config: Some(host_config), host_config: Some(host_config),
tty: Some(true), tty: Some(true),
..Default::default() ..Default::default()
@@ -276,10 +395,15 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
pub async fn remove_container(container_id: &str) -> Result<(), String> { pub async fn remove_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?; let docker = get_docker()?;
log::info!(
"Removing container {} (v=false: named volumes such as claude config are preserved)",
container_id
);
docker docker
.remove_container( .remove_container(
container_id, container_id,
Some(RemoveContainerOptions { Some(RemoveContainerOptions {
v: false, // preserve named volumes (claude config)
force: true, force: true,
..Default::default() ..Default::default()
}), }),
@@ -288,6 +412,162 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e)) .map_err(|e| format!("Failed to remove container: {}", e))
} }
/// 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).
pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_claude_instructions: Option<&str>,
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
.inspect_container(container_id, None)
.await
.map_err(|e| format!("Failed to inspect container: {}", e))?;
let labels = info
.config
.as_ref()
.and_then(|c| c.labels.as_ref());
let get_label = |name: &str| -> Option<String> {
labels.and_then(|l| l.get(name).cloned())
};
let mounts = info
.host_config
.as_ref()
.and_then(|hc| hc.mounts.as_ref());
// ── Docker socket mount ──────────────────────────────────────────────
// Intentionally NOT checked here. Toggling "Allow container spawning"
// should not trigger a full container recreation (which loses Claude
// Code settings stored in the named volume). The change takes effect
// on the next explicit rebuild instead.
// ── Auth mode ────────────────────────────────────────────────────────
let current_auth_mode = format!("{:?}", project.auth_mode);
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
if container_auth_mode != current_auth_mode {
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
return Ok(true);
}
}
// ── Project paths fingerprint ──────────────────────────────────────────
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
match get_label("triple-c.paths-fingerprint") {
Some(container_fp) => {
if container_fp != expected_paths_fp {
log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp);
return Ok(true);
}
}
None => {
// Old container without paths-fingerprint label -> force recreation for migration
log::info!("Container missing paths-fingerprint label, triggering recreation for migration");
return Ok(true);
}
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
if container_bedrock_fp != expected_bedrock_fp {
log::info!("Bedrock config mismatch");
return Ok(true);
}
// ── Image ────────────────────────────────────────────────────────────
// The image label is set at creation time; if the user changed the
// configured image we need to recreate. We only compare when the
// label exists (containers created before this change won't have it).
if let Some(container_image) = get_label("triple-c.image") {
// The caller doesn't pass the image name, but we can read the
// container's actual image from Docker inspect.
let actual_image = info
.config
.as_ref()
.and_then(|c| c.image.as_ref());
if let Some(actual) = actual_image {
if *actual != container_image {
log::info!("Image mismatch (actual={:?}, label={:?})", actual, container_image);
return Ok(true);
}
}
}
// ── SSH key path mount ───────────────────────────────────────────────
let ssh_mount_source = mounts
.and_then(|m| {
m.iter()
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
})
.and_then(|mount| mount.source.as_deref());
let project_ssh = project.ssh_key_path.as_deref();
if ssh_mount_source != project_ssh {
log::info!(
"SSH key path mismatch (container={:?}, project={:?})",
ssh_mount_source,
project_ssh
);
return Ok(true);
}
// ── Git environment variables ────────────────────────────────────────
let env_vars = info
.config
.as_ref()
.and_then(|c| c.env.as_ref());
let get_env = |name: &str| -> Option<String> {
env_vars.and_then(|vars| {
vars.iter()
.find(|v| v.starts_with(&format!("{}=", name)))
.map(|v| v[name.len() + 1..].to_string())
})
};
let container_git_name = get_env("GIT_USER_NAME");
let container_git_email = get_env("GIT_USER_EMAIL");
let container_git_token = get_env("GIT_TOKEN");
if container_git_name.as_deref() != project.git_user_name.as_deref() {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
return Ok(true);
}
if container_git_email.as_deref() != project.git_user_email.as_deref() {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
return Ok(true);
}
if container_git_token.as_deref() != project.git_token.as_deref() {
log::info!("GIT_TOKEN mismatch");
return Ok(true);
}
// ── Custom environment variables ──────────────────────────────────────
let expected_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
return Ok(true);
}
// ── Claude instructions ───────────────────────────────────────────────
let expected_instructions = merge_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
);
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
if container_instructions.as_deref() != expected_instructions.as_deref() {
log::info!("CLAUDE_INSTRUCTIONS mismatch");
return Ok(true);
}
Ok(false)
}
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> { pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
if let Some(ref container_id) = project.container_id { if let Some(ref container_id) = project.container_id {
let docker = get_docker()?; let docker = get_docker()?;

View File

@@ -9,6 +9,7 @@ use super::client::get_docker;
pub struct ExecSession { pub struct ExecSession {
pub exec_id: String, pub exec_id: String,
pub container_id: String,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>, pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>, shutdown_tx: mpsc::Sender<()>,
} }
@@ -140,6 +141,7 @@ impl ExecSessionManager {
let session = ExecSession { let session = ExecSession {
exec_id, exec_id,
container_id: container_id.to_string(),
input_tx, input_tx,
shutdown_tx, shutdown_tx,
}; };
@@ -161,11 +163,26 @@ impl ExecSessionManager {
} }
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> { pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let sessions = self.sessions.lock().await; // Clone the exec_id under the lock, then drop the lock before the
let session = sessions // async Docker API call to avoid holding the mutex across await.
.get(session_id) let exec_id = {
.ok_or_else(|| format!("Session {} not found", session_id))?; let sessions = self.sessions.lock().await;
session.resize(cols, rows).await let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.exec_id.clone()
};
let docker = get_docker()?;
docker
.resize_exec(
&exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
} }
pub async fn close_session(&self, session_id: &str) { pub async fn close_session(&self, session_id: &str) {
@@ -175,6 +192,20 @@ impl ExecSessionManager {
} }
} }
pub async fn close_sessions_for_container(&self, container_id: &str) {
let mut sessions = self.sessions.lock().await;
let ids_to_close: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.container_id == container_id)
.map(|(id, _)| id.clone())
.collect();
for id in ids_to_close {
if let Some(session) = sessions.remove(&id) {
session.shutdown();
}
}
}
pub async fn close_all_sessions(&self) { pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await; let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() { for (_, session) in sessions.drain() {

View File

@@ -6,6 +6,7 @@ mod storage;
use docker::exec::ExecSessionManager; use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore; use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore; use storage::settings_store::SettingsStore;
use tauri::Manager;
pub struct AppState { pub struct AppState {
pub projects_store: ProjectsStore, pub projects_store: ProjectsStore,
@@ -21,10 +22,18 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(AppState { .manage(AppState {
projects_store: ProjectsStore::new(), projects_store: ProjectsStore::new().expect("Failed to initialize projects store"),
settings_store: SettingsStore::new(), settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
exec_manager: ExecSessionManager::new(), exec_manager: ExecSessionManager::new(),
}) })
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let state = window.state::<AppState>();
tauri::async_runtime::block_on(async {
state.exec_manager.close_all_sessions().await;
});
}
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
// Docker // Docker
commands::docker_commands::check_docker, commands::docker_commands::check_docker,
@@ -54,6 +63,9 @@ pub fn run() {
commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,5 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ImageSource { pub enum ImageSource {
@@ -50,6 +54,12 @@ pub struct AppSettings {
pub custom_image_name: Option<String>, pub custom_image_name: Option<String>,
#[serde(default)] #[serde(default)]
pub global_aws: GlobalAwsSettings, pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_claude_instructions: Option<String>,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub dismissed_update_version: Option<String>,
} }
impl Default for AppSettings { impl Default for AppSettings {
@@ -62,6 +72,9 @@ impl Default for AppSettings {
image_source: ImageSource::default(), image_source: ImageSource::default(),
custom_image_name: None, custom_image_name: None,
global_aws: GlobalAwsSettings::default(), global_aws: GlobalAwsSettings::default(),
global_claude_instructions: None,
auto_check_updates: true,
dismissed_update_version: None,
} }
} }
} }

View File

@@ -1,7 +1,9 @@
pub mod project; pub mod project;
pub mod container_config; pub mod container_config;
pub mod app_settings; pub mod app_settings;
pub mod update_info;
pub use project::*; pub use project::*;
pub use container_config::*; pub use container_config::*;
pub use app_settings::*; pub use app_settings::*;
pub use update_info::*;

View File

@@ -1,19 +1,36 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvVar {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectPath {
pub host_path: String,
pub mount_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub path: String, pub paths: Vec<ProjectPath>,
pub container_id: Option<String>, pub container_id: Option<String>,
pub status: ProjectStatus, pub status: ProjectStatus,
pub auth_mode: AuthMode, pub auth_mode: AuthMode,
pub bedrock_config: Option<BedrockConfig>, pub bedrock_config: Option<BedrockConfig>,
pub allow_docker_access: bool, pub allow_docker_access: bool,
pub ssh_key_path: Option<String>, pub ssh_key_path: Option<String>,
#[serde(skip_serializing)]
pub git_token: Option<String>, pub git_token: Option<String>,
pub git_user_name: Option<String>, pub git_user_name: Option<String>,
pub git_user_email: Option<String>, pub git_user_email: Option<String>,
#[serde(default)]
pub custom_env_vars: Vec<EnvVar>,
#[serde(default)]
pub claude_instructions: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -66,22 +83,26 @@ impl Default for BedrockAuthMethod {
pub struct BedrockConfig { pub struct BedrockConfig {
pub auth_method: BedrockAuthMethod, pub auth_method: BedrockAuthMethod,
pub aws_region: String, pub aws_region: String,
#[serde(skip_serializing)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(skip_serializing)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(skip_serializing)]
pub aws_session_token: Option<String>, pub aws_session_token: Option<String>,
pub aws_profile: Option<String>, pub aws_profile: Option<String>,
#[serde(skip_serializing)]
pub aws_bearer_token: Option<String>, pub aws_bearer_token: Option<String>,
pub model_id: Option<String>, pub model_id: Option<String>,
pub disable_prompt_caching: bool, pub disable_prompt_caching: bool,
} }
impl Project { impl Project {
pub fn new(name: String, path: String) -> Self { pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
Self { Self {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name, name,
path, paths,
container_id: None, container_id: None,
status: ProjectStatus::Stopped, status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(), auth_mode: AuthMode::default(),
@@ -91,6 +112,8 @@ impl Project {
git_token: None, git_token: None,
git_user_name: None, git_user_name: None,
git_user_email: None, git_user_email: None,
custom_env_vars: Vec::new(),
claude_instructions: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
} }
@@ -99,4 +122,29 @@ impl Project {
pub fn container_name(&self) -> String { pub fn container_name(&self) -> String {
format!("triple-c-{}", self.id) format!("triple-c-{}", self.id)
} }
/// Migrate a project JSON value from old single-`path` format to new `paths` format.
/// If the value already has `paths`, it is returned unchanged.
pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value {
if let Some(obj) = val.as_object_mut() {
if obj.contains_key("paths") {
return val;
}
if let Some(path_val) = obj.remove("path") {
let path_str = path_val.as_str().unwrap_or("").to_string();
let mount_name = path_str
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.next()
.unwrap_or("workspace")
.to_string();
let project_path = serde_json::json!([{
"host_path": path_str,
"mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name },
}]);
obj.insert("paths".to_string(), project_path);
}
}
val
}
} }

View File

@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
/// Info returned to the frontend about an available update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub version: String,
pub tag_name: String,
pub release_url: String,
pub body: String,
pub assets: Vec<ReleaseAsset>,
pub published_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}
/// Gitea API release response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease {
pub tag_name: String,
pub html_url: String,
pub body: String,
pub assets: Vec<GiteaAsset>,
pub published_at: String,
}
/// Gitea API asset response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}

View File

@@ -10,42 +10,81 @@ pub struct ProjectsStore {
} }
impl ProjectsStore { impl ProjectsStore {
pub fn new() -> Self { pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir() let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".")) .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
.join("triple-c"); .join("triple-c");
fs::create_dir_all(&data_dir).ok(); fs::create_dir_all(&data_dir).ok();
let file_path = data_dir.join("projects.json"); let file_path = data_dir.join("projects.json");
let projects = if file_path.exists() { let (projects, needs_save) = if file_path.exists() {
match fs::read_to_string(&file_path) { match fs::read_to_string(&file_path) {
Ok(data) => match serde_json::from_str(&data) { Ok(data) => {
Ok(parsed) => parsed, // First try to parse as Vec<Value> to run migration
Err(e) => { match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e); Ok(raw_values) => {
// Back up the corrupted file let mut migrated = false;
let backup = file_path.with_extension("json.bak"); let migrated_values: Vec<serde_json::Value> = raw_values
if let Err(be) = fs::copy(&file_path, &backup) { .into_iter()
log::error!("Failed to back up corrupted projects.json: {}", be); .map(|v| {
let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths"));
if has_path {
migrated = true;
}
crate::models::Project::migrate_from_value(v)
})
.collect();
// Now deserialize the migrated values
let json_str = serde_json::to_string(&migrated_values).unwrap_or_default();
match serde_json::from_str::<Vec<crate::models::Project>>(&json_str) {
Ok(parsed) => (parsed, migrated),
Err(e) => {
log::error!("Failed to parse migrated projects.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 projects.json: {}", be);
}
(Vec::new(), false)
}
}
}
Err(e) => {
log::error!("Failed to parse projects.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 projects.json: {}", be);
}
(Vec::new(), false)
} }
Vec::new()
} }
}, }
Err(e) => { Err(e) => {
log::error!("Failed to read projects.json: {}", e); log::error!("Failed to read projects.json: {}", e);
Vec::new() (Vec::new(), false)
} }
} }
} else { } else {
Vec::new() (Vec::new(), false)
}; };
Self { let store = Self {
projects: Mutex::new(projects), projects: Mutex::new(projects),
file_path, file_path,
};
// Persist migrated format back to disk
if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format");
let projects = store.lock();
if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e);
}
} }
Ok(store)
} }
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> { fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {

View File

@@ -36,3 +36,49 @@ pub fn has_api_key() -> Result<bool, String> {
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
/// Store a per-project secret in the OS keychain.
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
let service = format!("triple-c-project-{}-{}", project_id, key_name);
let entry = keyring::Entry::new(&service, "secret")
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(value)
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
}
/// Retrieve a per-project secret from the OS keychain.
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
let service = format!("triple-c-project-{}-{}", project_id, key_name);
let entry = keyring::Entry::new(&service, "secret")
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
}
}
/// Delete all known secrets for a project from the OS keychain.
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
let secret_keys = [
"git-token",
"aws-access-key-id",
"aws-secret-access-key",
"aws-session-token",
"aws-bearer-token",
];
for key_name in &secret_keys {
let service = format!("triple-c-project-{}-{}", project_id, key_name);
let entry = keyring::Entry::new(&service, "secret")
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
Ok(()) => {}
Err(keyring::Error::NoEntry) => {}
Err(e) => {
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
}
}
}
Ok(())
}

View File

@@ -10,9 +10,9 @@ pub struct SettingsStore {
} }
impl SettingsStore { impl SettingsStore {
pub fn new() -> Self { pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir() let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".")) .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
.join("triple-c"); .join("triple-c");
fs::create_dir_all(&data_dir).ok(); fs::create_dir_all(&data_dir).ok();
@@ -41,10 +41,10 @@ impl SettingsStore {
AppSettings::default() AppSettings::default()
}; };
Self { Ok(Self {
settings: Mutex::new(settings), settings: Mutex::new(settings),
file_path, file_path,
} })
} }
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> { fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {

View File

@@ -22,7 +22,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
} }
}, },
"bundle": { "bundle": {

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar"; import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar"; import StatusBar from "./components/layout/StatusBar";
@@ -6,21 +7,35 @@ import TerminalView from "./components/terminal/TerminalView";
import { useDocker } from "./hooks/useDocker"; import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings"; import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState"; import { useAppState } from "./store/appState";
export default function App() { export default function App() {
const { checkDocker, checkImage } = useDocker(); const { checkDocker, checkImage } = useDocker();
const { checkApiKey, loadSettings } = useSettings(); const { checkApiKey, loadSettings } = useSettings();
const { refresh } = useProjects(); const { refresh } = useProjects();
const { sessions, activeSessionId } = useAppState(); const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
checkDocker(); checkDocker().then((available) => {
checkImage(); if (available) checkImage();
});
checkApiKey(); checkApiKey();
refresh(); refresh();
// Update detection
loadVersion();
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
const cleanup = startPeriodicCheck();
return () => {
clearTimeout(updateTimer);
cleanup?.();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (

View File

@@ -1,67 +0,0 @@
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>
);
}

View File

@@ -1,12 +1,15 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList"; import ProjectList from "../projects/ProjectList";
import SettingsPanel from "../settings/SettingsPanel"; import SettingsPanel from "../settings/SettingsPanel";
export default function Sidebar() { export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState(); const { sidebarView, setSidebarView } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
);
return ( return (
<div className="flex flex-col h-full w-64 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden"> <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 */} {/* Nav tabs */}
<div className="flex border-b border-[var(--border-color)]"> <div className="flex border-b border-[var(--border-color)]">
<button <button
@@ -32,7 +35,7 @@ export default function Sidebar() {
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto p-1">
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />} {sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,14 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
export default function StatusBar() { export default function StatusBar() {
const { projects, sessions } = useAppState(); const { projects, sessions } = useAppState(
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
);
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
return ( 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)]"> <div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
<span> <span>
{projects.length} project{projects.length !== 1 ? "s" : ""} {projects.length} project{projects.length !== 1 ? "s" : ""}
</span> </span>

View File

@@ -1,19 +1,62 @@
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import TerminalTabs from "../terminal/TerminalTabs"; import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
export default function TopBar() { export default function TopBar() {
const { dockerAvailable, imageExists } = useAppState(); const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
useShallow(s => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
updateInfo: s.updateInfo,
appVersion: s.appVersion,
setUpdateInfo: s.setUpdateInfo,
}))
);
const { appSettings, saveSettings } = useSettings();
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const handleDismiss = async () => {
if (appSettings && updateInfo) {
await saveSettings({
...appSettings,
dismissed_update_version: updateInfo.version,
});
}
setUpdateInfo(null);
setShowUpdateDialog(false);
};
return ( 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"> <div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<TerminalTabs /> <div className="flex-1 overflow-x-auto pl-2">
<TerminalTabs />
</div>
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
{updateInfo && (
<button
onClick={() => setShowUpdateDialog(true)}
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
>
Update
</button>
)}
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
</div>
</div> </div>
<div className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]"> {showUpdateDialog && updateInfo && (
<StatusDot ok={dockerAvailable === true} label="Docker" /> <UpdateDialog
<StatusDot ok={imageExists === true} label="Image" /> updateInfo={updateInfo}
</div> currentVersion={appVersion}
</div> onDismiss={handleDismiss}
onClose={() => setShowUpdateDialog(false)}
/>
)}
</>
); );
} }

View File

@@ -1,98 +1,211 @@
import { useState } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import type { ProjectPath } from "../../lib/types";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
} }
interface PathEntry {
host_path: string;
mount_name: string;
}
function basenameFromPath(p: string): string {
return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
}
export default function AddProjectDialog({ onClose }: Props) { export default function AddProjectDialog({ onClose }: Props) {
const { add } = useProjects(); const { add } = useProjects();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [path, setPath] = useState(""); const [pathEntries, setPathEntries] = useState<PathEntry[]>([
{ host_path: "", mount_name: "" },
]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const handleBrowse = async () => { useEffect(() => {
nameInputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const handleBrowse = async (index: number) => {
const selected = await open({ directory: true, multiple: false }); const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") { if (typeof selected === "string") {
setPath(selected); const basename = basenameFromPath(selected);
if (!name) { const entries = [...pathEntries];
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/); entries[index] = {
setName(parts[parts.length - 1]); host_path: selected,
mount_name: entries[index].mount_name || basename,
};
setPathEntries(entries);
// Auto-fill project name from first folder
if (!name && index === 0) {
setName(basename);
} }
} }
}; };
const handleSubmit = async () => { const updateEntry = (
if (!name.trim() || !path.trim()) { index: number,
setError("Name and path are required"); field: keyof PathEntry,
value: string,
) => {
const entries = [...pathEntries];
entries[index] = { ...entries[index], [field]: value };
setPathEntries(entries);
};
const removeEntry = (index: number) => {
setPathEntries(pathEntries.filter((_, i) => i !== index));
};
const addEntry = () => {
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
};
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
setError("Project name is required");
return;
}
const validPaths: ProjectPath[] = pathEntries
.filter((p) => p.host_path.trim())
.map((p) => ({
host_path: p.host_path.trim(),
mount_name: p.mount_name.trim() || basenameFromPath(p.host_path),
}));
if (validPaths.length === 0) {
setError("At least one folder path is required");
return;
}
const mountNames = validPaths.map((p) => p.mount_name);
if (new Set(mountNames).size !== mountNames.length) {
setError("Mount names must be unique");
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await add(name.trim(), path.trim()); await add(name.trim(), validPaths);
onClose(); onClose();
} catch (e) { } catch (err) {
setError(String(e)); setError(String(err));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl"> 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-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Add Project</h2> <h2 className="text-lg font-semibold mb-4">Add Project</h2>
<label className="block text-sm text-[var(--text-secondary)] mb-1"> <form onSubmit={handleSubmit}>
Project Name <label className="block text-sm text-[var(--text-secondary)] mb-1">
</label> Project Name
<input </label>
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 <input
value={path} ref={nameInputRef}
onChange={(e) => setPath(e.target.value)} value={name}
placeholder="/path/to/project" onChange={(e) => setName(e.target.value)}
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)]" 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)]"
/> />
<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 && ( <label className="block text-sm text-[var(--text-secondary)] mb-1">
<div className="text-xs text-[var(--error)] mb-3">{error}</div> Folders
)} </label>
<div className="space-y-2 mb-3">
{pathEntries.map((entry, i) => (
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
<div className="flex gap-1">
<input
value={entry.host_path}
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
placeholder="/path/to/folder"
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
/>
<button
type="button"
onClick={() => handleBrowse(i)}
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Browse
</button>
{pathEntries.length > 1 && (
<button
type="button"
onClick={() => removeEntry(i)}
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
x
</button>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
<input
value={entry.mount_name}
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
placeholder="mount-name"
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
/>
</div>
</div>
))}
</div>
<button
type="button"
onClick={addEntry}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
>
+ Add folder
</button>
<div className="flex justify-end gap-2"> {error && (
<button <div className="text-xs text-[var(--error)] mb-3">{error}</div>
onClick={onClose} )}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
> <div className="flex justify-end gap-2">
Cancel <button
</button> type="button"
<button onClick={onClose}
onClick={handleSubmit} className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
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" Cancel
> </button>
{loading ? "Adding..." : "Add Project"} <button
</button> type="submit"
</div> 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>
</form>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
@@ -10,7 +10,8 @@ interface Props {
} }
export default function ProjectCard({ project }: Props) { export default function ProjectCard({ project }: Props) {
const { selectedProjectId, setSelectedProject } = useAppState(); const selectedProjectId = useAppState(s => s.selectedProjectId);
const setSelectedProject = useAppState(s => s.setSelectedProject);
const { start, stop, rebuild, remove, update } = useProjects(); const { start, stop, rebuild, remove, update } = useProjects();
const { open: openTerminal } = useTerminal(); const { open: openTerminal } = useTerminal();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -19,6 +20,42 @@ export default function ProjectCard({ project }: Props) {
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error"; const isStopped = project.status === "stopped" || project.status === "error";
// Local state for text fields (save on blur, not on every keystroke)
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
const [gitName, setGitName] = useState(project.git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
const [gitToken, setGitToken] = useState(project.git_token ?? "");
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
// Bedrock local state for text fields
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? "");
const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? "");
const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? "");
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
// Sync local state when project prop changes (e.g., after save or external update)
useEffect(() => {
setPaths(project.paths ?? []);
setSshKeyPath(project.ssh_key_path ?? "");
setGitName(project.git_user_name ?? "");
setGitEmail(project.git_user_email ?? "");
setGitToken(project.git_token ?? "");
setClaudeInstructions(project.claude_instructions ?? "");
setEnvVars(project.custom_env_vars ?? []);
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? "");
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
setBedrockModelId(project.bedrock_config?.model_id ?? "");
}, [project]);
const handleStart = async () => { const handleStart = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -79,7 +116,9 @@ export default function ProjectCard({ project }: Props) {
try { try {
const current = project.bedrock_config ?? defaultBedrockConfig; const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, ...patch } }); await update({ ...project, bedrock_config: { ...current, ...patch } });
} catch {} } catch (err) {
console.error("Failed to update Bedrock config:", err);
}
}; };
const handleBrowseSSH = async () => { const handleBrowseSSH = async () => {
@@ -93,6 +132,118 @@ export default function ProjectCard({ project }: Props) {
} }
}; };
// Blur handlers for text fields
const handleSshKeyPathBlur = async () => {
try {
await update({ ...project, ssh_key_path: sshKeyPath || null });
} catch (err) {
console.error("Failed to update SSH key path:", err);
}
};
const handleGitNameBlur = async () => {
try {
await update({ ...project, git_user_name: gitName || null });
} catch (err) {
console.error("Failed to update Git name:", err);
}
};
const handleGitEmailBlur = async () => {
try {
await update({ ...project, git_user_email: gitEmail || null });
} catch (err) {
console.error("Failed to update Git email:", err);
}
};
const handleGitTokenBlur = async () => {
try {
await update({ ...project, git_token: gitToken || null });
} catch (err) {
console.error("Failed to update Git token:", err);
}
};
const handleClaudeInstructionsBlur = async () => {
try {
await update({ ...project, claude_instructions: claudeInstructions || null });
} catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
const handleEnvVarBlur = async () => {
try {
await update({ ...project, custom_env_vars: envVars });
} catch (err) {
console.error("Failed to update environment variables:", err);
}
};
const handleBedrockRegionBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_region: bedrockRegion } });
} catch (err) {
console.error("Failed to update Bedrock region:", err);
}
};
const handleBedrockAccessKeyIdBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_access_key_id: bedrockAccessKeyId || null } });
} catch (err) {
console.error("Failed to update Bedrock access key:", err);
}
};
const handleBedrockSecretKeyBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_secret_access_key: bedrockSecretKey || null } });
} catch (err) {
console.error("Failed to update Bedrock secret key:", err);
}
};
const handleBedrockSessionTokenBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_session_token: bedrockSessionToken || null } });
} catch (err) {
console.error("Failed to update Bedrock session token:", err);
}
};
const handleBedrockProfileBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_profile: bedrockProfile || null } });
} catch (err) {
console.error("Failed to update Bedrock profile:", err);
}
};
const handleBedrockBearerTokenBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, aws_bearer_token: bedrockBearerToken || null } });
} catch (err) {
console.error("Failed to update Bedrock bearer token:", err);
}
};
const handleBedrockModelIdBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, model_id: bedrockModelId || null } });
} catch (err) {
console.error("Failed to update Bedrock model ID:", err);
}
};
const statusColor = { const statusColor = {
stopped: "bg-[var(--text-secondary)]", stopped: "bg-[var(--text-secondary)]",
starting: "bg-[var(--warning)]", starting: "bg-[var(--warning)]",
@@ -114,8 +265,12 @@ export default function ProjectCard({ project }: Props) {
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} /> <span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
<span className="text-sm font-medium truncate flex-1">{project.name}</span> <span className="text-sm font-medium truncate flex-1">{project.name}</span>
</div> </div>
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4"> <div className="mt-0.5 ml-4 space-y-0.5">
{project.path} {project.paths.map((pp, i) => (
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
<span className="font-mono">/workspace/{pp.mount_name}</span>
</div>
))}
</div> </div>
{isSelected && ( {isSelected && (
@@ -159,7 +314,7 @@ export default function ProjectCard({ project }: Props) {
</div> </div>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-wrap">
{isStopped ? ( {isStopped ? (
<> <>
<ActionButton onClick={handleStart} disabled={loading} label="Start" /> <ActionButton onClick={handleStart} disabled={loading} label="Start" />
@@ -203,15 +358,99 @@ export default function ProjectCard({ project }: Props) {
{/* Config panel */} {/* Config panel */}
{showConfig && ( {showConfig && (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}> <div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
{/* Folder paths */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
{paths.map((pp, i) => (
<div key={i} className="flex gap-1 mb-1 items-center">
<input
value={pp.host_path}
onChange={(e) => {
const updated = [...paths];
updated[i] = { ...updated[i], host_path: e.target.value };
setPaths(updated);
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}}
placeholder="/path/to/folder"
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={async () => {
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") {
const updated = [...paths];
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to update paths:", err);
}
}
}}
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>
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
<input
value={pp.mount_name}
onChange={(e) => {
const updated = [...paths];
updated[i] = { ...updated[i], mount_name: e.target.value };
setPaths(updated);
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}}
placeholder="name"
disabled={!isStopped}
className="w-20 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 font-mono"
/>
{paths.length > 1 && (
<button
onClick={async () => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to remove path:", err);
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
)}
</div>
))}
<button
onClick={async () => {
const updated = [...paths, { host_path: "", mount_name: "" }];
setPaths(updated);
}}
disabled={!isStopped}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add folder
</button>
</div>
{/* SSH Key */} {/* SSH Key */}
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<div className="flex gap-1"> <div className="flex gap-1">
<input <input
value={project.ssh_key_path ?? ""} value={sshKeyPath}
onChange={async (e) => { onChange={(e) => setSshKeyPath(e.target.value)}
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {} onBlur={handleSshKeyPathBlur}
}}
placeholder="~/.ssh" placeholder="~/.ssh"
disabled={!isStopped} 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" 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"
@@ -230,10 +469,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<input <input
value={project.git_user_name ?? ""} value={gitName}
onChange={async (e) => { onChange={(e) => setGitName(e.target.value)}
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {} onBlur={handleGitNameBlur}
}}
placeholder="Your Name" placeholder="Your Name"
disabled={!isStopped} 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" 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"
@@ -244,10 +482,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<input <input
value={project.git_user_email ?? ""} value={gitEmail}
onChange={async (e) => { onChange={(e) => setGitEmail(e.target.value)}
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {} onBlur={handleGitEmailBlur}
}}
placeholder="you@example.com" placeholder="you@example.com"
disabled={!isStopped} 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" 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"
@@ -259,10 +496,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<input <input
type="password" type="password"
value={project.git_token ?? ""} value={gitToken}
onChange={async (e) => { onChange={(e) => setGitToken(e.target.value)}
try { await update({ ...project, git_token: e.target.value || null }); } catch {} onBlur={handleGitTokenBlur}
}}
placeholder="ghp_..." placeholder="ghp_..."
disabled={!isStopped} 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" 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"
@@ -274,7 +510,9 @@ export default function ProjectCard({ project }: Props) {
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label> <label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<button <button
onClick={async () => { onClick={async () => {
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {} try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
console.error("Failed to update Docker access setting:", err);
}
}} }}
disabled={!isStopped} disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${ className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
@@ -287,6 +525,79 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Environment Variables */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
{envVars.map((ev, i) => (
<div key={i} className="flex gap-1 mb-1">
<input
value={ev.key}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], key: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="KEY"
disabled={!isStopped}
className="w-1/3 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 font-mono"
/>
<input
value={ev.value}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], value: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="value"
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 font-mono"
/>
<button
onClick={async () => {
const vars = envVars.filter((_, j) => j !== i);
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
<button
onClick={async () => {
const vars = [...envVars, { key: "", value: "" }];
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to add environment variable:", err);
}
}}
disabled={!isStopped}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add variable
</button>
</div>
{/* Claude Instructions */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
<textarea
value={claudeInstructions}
onChange={(e) => setClaudeInstructions(e.target.value)}
onBlur={handleClaudeInstructionsBlur}
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
disabled={!isStopped}
rows={3}
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 resize-y font-mono"
/>
</div>
{/* Bedrock config */} {/* Bedrock config */}
{project.auth_mode === "bedrock" && (() => { {project.auth_mode === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig; const bc = project.bedrock_config ?? defaultBedrockConfig;
@@ -318,8 +629,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
<input <input
value={bc.aws_region} value={bedrockRegion}
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })} onChange={(e) => setBedrockRegion(e.target.value)}
onBlur={handleBedrockRegionBlur}
placeholder="us-east-1" placeholder="us-east-1"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
@@ -332,8 +644,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
<input <input
value={bc.aws_access_key_id ?? ""} value={bedrockAccessKeyId}
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })} onChange={(e) => setBedrockAccessKeyId(e.target.value)}
onBlur={handleBedrockAccessKeyIdBlur}
placeholder="AKIA..." placeholder="AKIA..."
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
@@ -343,8 +656,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
<input <input
type="password" type="password"
value={bc.aws_secret_access_key ?? ""} value={bedrockSecretKey}
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })} onChange={(e) => setBedrockSecretKey(e.target.value)}
onBlur={handleBedrockSecretKeyBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
@@ -353,8 +667,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
<input <input
type="password" type="password"
value={bc.aws_session_token ?? ""} value={bedrockSessionToken}
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })} onChange={(e) => setBedrockSessionToken(e.target.value)}
onBlur={handleBedrockSessionTokenBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
@@ -367,8 +682,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
<input <input
value={bc.aws_profile ?? ""} value={bedrockProfile}
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })} onChange={(e) => setBedrockProfile(e.target.value)}
onBlur={handleBedrockProfileBlur}
placeholder="default" placeholder="default"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
@@ -382,8 +698,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
<input <input
type="password" type="password"
value={bc.aws_bearer_token ?? ""} value={bedrockBearerToken}
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })} onChange={(e) => setBedrockBearerToken(e.target.value)}
onBlur={handleBedrockBearerTokenBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
@@ -394,8 +711,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
<input <input
value={bc.model_id ?? ""} value={bedrockModelId}
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })} onChange={(e) => setBedrockModelId(e.target.value)}
onBlur={handleBedrockModelIdBlur}
placeholder="anthropic.claude-sonnet-4-20250514-v1:0" placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}

View File

@@ -8,7 +8,7 @@ export default function ProjectList() {
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
return ( return (
<div className="p-2"> <div className="p-3">
<div className="flex items-center justify-between px-2 py-1 mb-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)]"> <span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Projects Projects

View File

@@ -83,7 +83,7 @@ export default function AwsSettings() {
<select <select
value={globalAws.aws_profile ?? ""} value={globalAws.aws_profile ?? ""}
onChange={(e) => handleChange("aws_profile", e.target.value)} onChange={(e) => handleChange("aws_profile", e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
> >
<option value="">None (use default)</option> <option value="">None (use default)</option>
{profiles.map((p) => ( {profiles.map((p) => (

View File

@@ -33,8 +33,7 @@ export default function DockerSettings() {
const handleSourceChange = async (source: ImageSource) => { const handleSourceChange = async (source: ImageSource) => {
if (!appSettings) return; if (!appSettings) return;
await saveSettings({ ...appSettings, image_source: source }); await saveSettings({ ...appSettings, image_source: source });
// Re-check image existence after changing source await checkImage();
setTimeout(() => checkImage(), 100);
}; };
const handleCustomChange = async (value: string) => { const handleCustomChange = async (value: string) => {

View File

@@ -1,8 +1,40 @@
import { useState, useEffect } from "react";
import ApiKeyInput from "./ApiKeyInput"; import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings"; import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings"; import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
export default function SettingsPanel() { export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [checkingUpdates, setCheckingUpdates] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
}, [appSettings?.global_claude_instructions]);
const handleInstructionsBlur = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
};
const handleCheckNow = async () => {
setCheckingUpdates(true);
try {
await checkForUpdates();
} finally {
setCheckingUpdates(false);
}
};
const handleAutoCheckToggle = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
};
return ( return (
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]"> <h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
@@ -11,6 +43,52 @@ export default function SettingsPanel() {
<ApiKeyInput /> <ApiKeyInput />
<DockerSettings /> <DockerSettings />
<AwsSettings /> <AwsSettings />
<div>
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p>
<textarea
value={globalInstructions}
onChange={(e) => setGlobalInstructions(e.target.value)}
onBlur={handleInstructionsBlur}
placeholder="Instructions for Claude Code in all project containers..."
rows={4}
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono"
/>
</div>
{/* Updates section */}
<div>
<label className="block text-sm font-medium mb-2">Updates</label>
<div className="space-y-2">
{appVersion && (
<p className="text-xs text-[var(--text-secondary)]">
Current version: <span className="text-[var(--text-primary)] font-mono">{appVersion}</span>
</p>
)}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
<button
onClick={handleAutoCheckToggle}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
appSettings?.auto_check_updates !== false
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{appSettings?.auto_check_updates !== false ? "ON" : "OFF"}
</button>
</div>
<button
onClick={handleCheckNow}
disabled={checkingUpdates}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
>
{checkingUpdates ? "Checking..." : "Check now"}
</button>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useCallback } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
interface Props {
updateInfo: UpdateInfo;
currentVersion: string;
onDismiss: () => void;
onClose: () => void;
}
export default function UpdateDialog({
updateInfo,
currentVersion,
onDismiss,
onClose,
}: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const handleDownload = async (url: string) => {
try {
await openUrl(url);
} catch (e) {
console.error("Failed to open URL:", e);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
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-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
<h2 className="text-lg font-semibold mb-3">Update Available</h2>
<div className="flex items-center gap-2 mb-4 text-sm">
<span className="text-[var(--text-secondary)]">{currentVersion}</span>
<span className="text-[var(--text-secondary)]">&rarr;</span>
<span className="text-[var(--accent)] font-semibold">
{updateInfo.version}
</span>
</div>
{updateInfo.body && (
<div className="mb-4">
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
Release Notes
</h3>
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
{updateInfo.body}
</div>
</div>
)}
{updateInfo.assets.length > 0 && (
<div className="mb-4 space-y-1">
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
Downloads
</h3>
{updateInfo.assets.map((asset) => (
<button
key={asset.name}
onClick={() => handleDownload(asset.browser_download_url)}
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent)] transition-colors"
>
<span className="truncate">{asset.name}</span>
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
{formatSize(asset.size)}
</span>
</button>
))}
</div>
)}
<div className="flex items-center justify-between">
<button
onClick={() => handleDownload(updateInfo.release_url)}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
View on Gitea
</button>
<div className="flex gap-2">
<button
onClick={onDismiss}
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Dismiss
</button>
<button
onClick={onClose}
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"
>
Close
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -7,11 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
/** Strip ANSI escape sequences from a string. */
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
}
interface Props { interface Props {
sessionId: string; sessionId: string;
active: boolean; active: boolean;
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal(); const { sendInput, resize, onOutput, onExit } = useTerminal();
useEffect(() => { useEffect(() => {
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current); term.open(containerRef.current);
// Try WebGL renderer, fall back silently // WebGL addon is loaded/disposed dynamically in the active effect
try { // to avoid exhausting the browser's limited WebGL context pool.
const webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
} catch {
// WebGL not available, canvas renderer is fine
}
fitAddon.fit(); fitAddon.fit();
termRef.current = term; termRef.current = term;
@@ -88,81 +79,84 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// ── URL accumulator ──────────────────────────────────────────────
// Claude Code login emits a long OAuth URL that gets split across
// hard newlines (\n / \r\n). The WebLinksAddon only joins
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
// truncated and the link fails when clicked.
//
// Fix: buffer recent output, strip ANSI codes, and after a short
// debounce check for a URL that spans multiple lines. When found,
// write a single clean clickable copy to the terminal.
let outputBuffer = "";
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const flushUrlBuffer = () => {
const plain = stripAnsi(outputBuffer);
// Reassemble: strip hard newlines and carriage returns to join
// fragments that were split across terminal lines.
const joined = plain.replace(/[\r\n]+/g, "");
// Look for a long OAuth/auth URL (Claude login URLs contain
// "oauth" or "console.anthropic.com" or "/authorize").
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
if (match) {
const url = match[0];
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
}
outputBuffer = "";
};
// Handle backend output -> terminal // Handle backend output -> terminal
let unlistenOutput: (() => void) | null = null; let aborted = false;
let unlistenExit: (() => void) | null = null;
onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data); term.write(data);
// Accumulate for URL detection
outputBuffer += data;
// Cap buffer size to avoid memory growth
if (outputBuffer.length > 8192) {
outputBuffer = outputBuffer.slice(-4096);
}
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(flushUrlBuffer, 150);
}).then((unlisten) => { }).then((unlisten) => {
unlistenOutput = unlisten; if (aborted) unlisten();
return unlisten;
}); });
onExit(sessionId, () => { const exitPromise = onExit(sessionId, () => {
if (aborted) return;
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n"); term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
}).then((unlisten) => { }).then((unlisten) => {
unlistenExit = unlisten; if (aborted) unlisten();
return unlisten;
}); });
// Handle resize // Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
let resizeRafId: number | null = null;
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
fitAddon.fit(); if (resizeRafId !== null) return;
resize(sessionId, term.cols, term.rows); const el = containerRef.current;
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
});
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
return () => { return () => {
if (debounceTimer) clearTimeout(debounceTimer); aborted = true;
inputDisposable.dispose(); inputDisposable.dispose();
unlistenOutput?.(); outputPromise.then((fn) => fn?.());
unlistenExit?.(); exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose(); term.dispose();
}; };
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-fit when tab becomes active // Manage WebGL lifecycle and re-fit when tab becomes active.
// Only the active terminal holds a WebGL context to avoid exhausting
// the browser's limited pool (~8-16 contexts).
useEffect(() => { useEffect(() => {
if (active && fitRef.current && termRef.current) { const term = termRef.current;
fitRef.current.fit(); if (!term) return;
termRef.current.focus();
if (active) {
// Attach WebGL renderer
if (!webglRef.current) {
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
try { addon.dispose(); } catch { /* ignore */ }
webglRef.current = null;
});
term.loadAddon(addon);
webglRef.current = addon;
} catch {
// WebGL not available, canvas renderer is fine
}
}
fitRef.current?.fit();
term.focus();
} else {
// Release WebGL context for inactive terminals
if (webglRef.current) {
try { webglRef.current.dispose(); } catch { /* ignore */ }
webglRef.current = null;
}
} }
}, [active]); }, [active]);
@@ -170,7 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) {
<div <div
ref={containerRef} ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`} className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "4px" }} style={{ padding: "8px" }}
/> />
); );
} }

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
@@ -9,7 +10,14 @@ export function useDocker() {
setDockerAvailable, setDockerAvailable,
imageExists, imageExists,
setImageExists, setImageExists,
} = useAppState(); } = useAppState(
useShallow(s => ({
dockerAvailable: s.dockerAvailable,
setDockerAvailable: s.setDockerAvailable,
imageExists: s.imageExists,
setImageExists: s.setImageExists,
}))
);
const checkDocker = useCallback(async () => { const checkDocker = useCallback(async () => {
try { try {

View File

@@ -1,6 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import type { ProjectPath } from "../lib/types";
export function useProjects() { export function useProjects() {
const { const {
@@ -10,7 +12,16 @@ export function useProjects() {
setSelectedProject, setSelectedProject,
updateProjectInList, updateProjectInList,
removeProjectFromList, removeProjectFromList,
} = useAppState(); } = useAppState(
useShallow(s => ({
projects: s.projects,
selectedProjectId: s.selectedProjectId,
setProjects: s.setProjects,
setSelectedProject: s.setSelectedProject,
updateProjectInList: s.updateProjectInList,
removeProjectFromList: s.removeProjectFromList,
}))
);
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null; const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
@@ -20,8 +31,8 @@ export function useProjects() {
}, [setProjects]); }, [setProjects]);
const add = useCallback( const add = useCallback(
async (name: string, path: string) => { async (name: string, paths: ProjectPath[]) => {
const project = await commands.addProject(name, path); const project = await commands.addProject(name, paths);
// Refresh from backend to avoid stale closure issues // Refresh from backend to avoid stale closure issues
const list = await commands.listProjects(); const list = await commands.listProjects();
setProjects(list); setProjects(list);

View File

@@ -1,10 +1,18 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import type { AppSettings } from "../lib/types"; import type { AppSettings } from "../lib/types";
export function useSettings() { export function useSettings() {
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(); const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
useShallow(s => ({
hasKey: s.hasKey,
setHasKey: s.setHasKey,
appSettings: s.appSettings,
setAppSettings: s.setAppSettings,
}))
);
const checkApiKey = useCallback(async () => { const checkApiKey = useCallback(async () => {
try { try {

View File

@@ -1,11 +1,20 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
export function useTerminal() { export function useTerminal() {
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } = const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
useAppState(); useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
addSession: s.addSession,
removeSession: s.removeSession,
setActiveSession: s.setActiveSession,
}))
);
const open = useCallback( const open = useCallback(
async (projectId: string, projectName: string) => { async (projectId: string, projectName: string) => {

View File

@@ -0,0 +1,72 @@
import { useCallback, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export function useUpdates() {
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
useAppState(
useShallow((s) => ({
updateInfo: s.updateInfo,
setUpdateInfo: s.setUpdateInfo,
appVersion: s.appVersion,
setAppVersion: s.setAppVersion,
appSettings: s.appSettings,
})),
);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadVersion = useCallback(async () => {
try {
const version = await commands.getAppVersion();
setAppVersion(version);
} catch (e) {
console.error("Failed to load app version:", e);
}
}, [setAppVersion]);
const checkForUpdates = useCallback(async () => {
try {
const info = await commands.checkForUpdates();
if (info) {
// Respect dismissed version
const dismissed = appSettings?.dismissed_update_version;
if (dismissed && dismissed === info.version) {
setUpdateInfo(null);
return null;
}
}
setUpdateInfo(info);
return info;
} catch (e) {
console.error("Failed to check for updates:", e);
return null;
}
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
const startPeriodicCheck = useCallback(() => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
if (appSettings?.auto_check_updates !== false) {
checkForUpdates();
}
}, CHECK_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [checkForUpdates, appSettings?.auto_check_updates]);
return {
updateInfo,
appVersion,
loadVersion,
checkForUpdates,
startPeriodicCheck,
};
}

View File

@@ -12,13 +12,9 @@
--success: #3fb950; --success: #3fb950;
--warning: #d29922; --warning: #d29922;
--error: #f85149; --error: #f85149;
color-scheme: dark;
} }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root { html, body, #root {
height: 100%; height: 100%;

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types"; import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -12,8 +12,8 @@ export const listSiblingContainers = () =>
// Projects // Projects
export const listProjects = () => invoke<Project[]>("list_projects"); export const listProjects = () => invoke<Project[]>("list_projects");
export const addProject = (name: string, path: string) => export const addProject = (name: string, paths: ProjectPath[]) =>
invoke<Project>("add_project", { name, path }); invoke<Project>("add_project", { name, paths });
export const removeProject = (projectId: string) => export const removeProject = (projectId: string) =>
invoke<void>("remove_project", { projectId }); invoke<void>("remove_project", { projectId });
export const updateProject = (project: Project) => export const updateProject = (project: Project) =>
@@ -49,3 +49,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows }); invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) => export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
export const checkForUpdates = () =>
invoke<UpdateInfo | null>("check_for_updates");

View File

@@ -1,7 +1,17 @@
export interface EnvVar {
key: string;
value: string;
}
export interface ProjectPath {
host_path: string;
mount_name: string;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
path: string; paths: ProjectPath[];
container_id: string | null; container_id: string | null;
status: ProjectStatus; status: ProjectStatus;
auth_mode: AuthMode; auth_mode: AuthMode;
@@ -11,6 +21,8 @@ export interface Project {
git_token: string | null; git_token: string | null;
git_user_name: string | null; git_user_name: string | null;
git_user_email: string | null; git_user_email: string | null;
custom_env_vars: EnvVar[];
claude_instructions: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -75,4 +87,22 @@ export interface AppSettings {
image_source: ImageSource; image_source: ImageSource;
custom_image_name: string | null; custom_image_name: string | null;
global_aws: GlobalAwsSettings; global_aws: GlobalAwsSettings;
global_claude_instructions: string | null;
auto_check_updates: boolean;
dismissed_update_version: string | null;
}
export interface UpdateInfo {
version: string;
tag_name: string;
release_url: string;
body: string;
assets: ReleaseAsset[];
published_at: string;
}
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size: number;
} }

View File

@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { Project, TerminalSession, AppSettings } from "../lib/types"; import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
interface AppState { interface AppState {
// Projects // Projects
@@ -30,6 +30,12 @@ interface AppState {
// App settings // App settings
appSettings: AppSettings | null; appSettings: AppSettings | null;
setAppSettings: (settings: AppSettings) => void; setAppSettings: (settings: AppSettings) => void;
// Update info
updateInfo: UpdateInfo | null;
setUpdateInfo: (info: UpdateInfo | null) => void;
appVersion: string;
setAppVersion: (version: string) => void;
} }
export const useAppState = create<AppState>((set) => ({ export const useAppState = create<AppState>((set) => ({
@@ -85,4 +91,10 @@ export const useAppState = create<AppState>((set) => ({
// App settings // App settings
appSettings: null, appSettings: null,
setAppSettings: (settings) => set({ appSettings: settings }), setAppSettings: (settings) => set({ appSettings: settings }),
// Update info
updateInfo: null,
setUpdateInfo: (info) => set({ updateInfo: info }),
appVersion: "",
setAppVersion: (version) => set({ appVersion: version }),
})); }));

View File

@@ -50,9 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \
python3-pip \ python3-pip \
python3-venv \ python3-venv \
&& rm -rf /var/lib/apt/lists/* \ && 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) ───────────────────────────────────────────────── # ── Docker CLI (not daemon) ─────────────────────────────────────────────────
RUN install -m 0755 -d /etc/apt/keyrings \ RUN install -m 0755 -d /etc/apt/keyrings \
@@ -65,8 +63,11 @@ RUN install -m 0755 -d /etc/apt/keyrings \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ── AWS CLI v2 ─────────────────────────────────────────────────────────────── # ── AWS CLI v2 ───────────────────────────────────────────────────────────────
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ RUN ARCH=$(uname -m) && \
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws curl "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o "awscliv2.zip" && \
unzip -q awscliv2.zip && \
./aws/install && \
rm -rf awscliv2.zip aws
# ── Non-root user with passwordless sudo ───────────────────────────────────── # ── Non-root user with passwordless sudo ─────────────────────────────────────
RUN useradd -m -s /bin/bash -u 1000 claude \ RUN useradd -m -s /bin/bash -u 1000 claude \
@@ -83,7 +84,7 @@ WORKDIR /home/claude
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/home/claude/.cargo/bin:${PATH}" ENV PATH="/home/claude/.cargo/bin:${PATH}"
# Add uv/ruff to PATH (installed to /root by default, reinstall for claude user) # Install uv and ruff for claude user
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& curl -LsSf https://astral.sh/ruff/install.sh | sh && curl -LsSf https://astral.sh/ruff/install.sh | sh
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}" ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"

View File

@@ -82,16 +82,25 @@ if [ -n "$GIT_TOKEN" ]; then
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$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}@gitlab.com" >> "$CRED_FILE"
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE" echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
su -s /bin/bash claude -c "git config --global credential.helper 'store --file=$CRED_FILE'" git config --global --file /home/claude/.gitconfig credential.helper "store --file=$CRED_FILE"
unset GIT_TOKEN unset GIT_TOKEN
fi fi
# ── Git user config ────────────────────────────────────────────────────────── # ── Git user config ──────────────────────────────────────────────────────────
if [ -n "$GIT_USER_NAME" ]; then if [ -n "$GIT_USER_NAME" ]; then
su -s /bin/bash claude -c "git config --global user.name '$GIT_USER_NAME'" git config --global --file /home/claude/.gitconfig user.name "$GIT_USER_NAME"
fi fi
if [ -n "$GIT_USER_EMAIL" ]; then if [ -n "$GIT_USER_EMAIL" ]; then
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'" git config --global --file /home/claude/.gitconfig user.email "$GIT_USER_EMAIL"
fi
chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
# ── Claude instructions ──────────────────────────────────────────────────────
if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
mkdir -p /home/claude/.claude
printf '%s\n' "$CLAUDE_INSTRUCTIONS" > /home/claude/.claude/CLAUDE.md
chown claude:claude /home/claude/.claude/CLAUDE.md
unset CLAUDE_INSTRUCTIONS
fi fi
# ── Docker socket permissions ──────────────────────────────────────────────── # ── Docker socket permissions ────────────────────────────────────────────────