Compare commits

..

24 Commits

Author SHA1 Message Date
01ea581f8a Fix type inference error for api_key after removing ApiKey auth mode
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 2m44s
Both match arms now return None, so Rust needs an explicit type
annotation for the Option<String>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:15:50 +00:00
552aaebf16 Simplify auth modes to Anthropic and Bedrock, fix Windows taskbar icon
Some checks failed
Build App / build-linux (push) Failing after 1m40s
Build App / build-windows (push) Failing after 1m43s
Replace the three auth modes (Login, API Key, Bedrock) with two
(Anthropic, Bedrock). The Anthropic mode uses OAuth via `claude login`
inside the terminal, which generates and stores its own API key in the
persistent config volume. The separate API Key mode is removed because
Claude Code now requires interactive approval of externally-provided
keys, making the injected ANTHROPIC_API_KEY approach unreliable.

Old projects stored as "login" or "api_key" are automatically migrated
to "anthropic" via serde aliases.

Also fix the Windows taskbar icon showing as a black square by loading
icon.png instead of icon.ico for the runtime window icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:10:57 +00:00
c2736ace90 Fix API key changes not triggering container recreation
All checks were successful
Build App / build-linux (push) Successful in 2m45s
Build App / build-windows (push) Successful in 4m15s
The container was only recreated when the auth mode changed, not when
the API key value itself changed. This meant saving a new key required
a manual container rebuild. Now we store a hash of the API key as a
Docker label and compare it on start, so a key change automatically
recreates the container (preserving the claude config volume).

Also adds a note to the global AWS settings UI that changes require a
container rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:22:34 +00:00
2ff270ebfe Fix Windows crash from missing ICO decoder and add file logging
All checks were successful
Build App / build-linux (push) Successful in 3m7s
Build App / build-windows (push) Successful in 4m19s
The app crashed on startup because the image-ico Tauri feature was
missing, causing Image::from_bytes to panic when decoding icon.ico.
Added the feature flag and replaced env_logger with fern to log to both
stderr and <data_dir>/triple-c/logs/triple-c.log. A panic hook captures
crash details with backtraces. Store init and icon loading errors are now
logged before failing so future issues are diagnosable from the log file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:45:59 +00:00
5a59fdb64b Add UX enhancements: modals for env vars and instructions, global env vars, taskbar icon fix
All checks were successful
Build App / build-linux (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m5s
- Fix Windows taskbar icon by loading icon.ico instead of icon.png (ICO contains
  multiple sizes native to Windows taskbar/title bar/alt-tab)
- Add "Container must be stopped to change settings" warning banner in config panel
- Move per-project Environment Variables and Claude Instructions into modal dialogs
  for more editing space, with buttons in the config panel to open them
- Move global Claude Instructions into a modal in Settings panel
- Add default global Claude instruction recommending git initialization
- Add global environment variables support (full stack: Rust model, TS types,
  container creation with merge logic where project overrides global for same key,
  fingerprinting for recreation checks, and Settings UI with modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:21:33 +00:00
1ce5151e59 Fix Rust build errors: use setup hook for window icon, enable image-png feature
All checks were successful
Build App / build-linux (push) Successful in 3m1s
Build App / build-windows (push) Successful in 3m49s
The previous approach used Builder::default_window_icon() which doesn't
exist in Tauri 2.10. Instead, set the icon via window.set_icon() in the
setup hook, and enable the "image-png" feature flag so Image::from_bytes
can decode the PNG icon at runtime.

Also change bundle identifier from "com.triple-c.app" to
"com.triple-c.desktop" to avoid conflicting with the .app bundle
extension on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:49:08 +00:00
66ddc182c9 Exclude test files from tsc build to fix CI
Some checks failed
Build App / build-linux (push) Failing after 1m30s
Build App / build-windows (push) Failing after 2m25s
The build step runs `tsc && vite build`, and the test files that import
Node.js built-ins (fs, path) and use __dirname were causing TS2307 and
TS2304 errors during compilation. Excluding test files from tsconfig
keeps the build clean while vitest handles its own TypeScript resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:42:35 +00:00
1524ec4a98 Fix sidebar content overflow and set correct window taskbar icon
Some checks failed
Build App / build-windows (push) Failing after 41s
Build App / build-linux (push) Failing after 1m25s
The sidebar config panel content was overflowing its container width,
causing project names and directory paths to be clipped. Added min-w-0
and overflow-hidden to flex containers, and restructured folder path
config rows to stack vertically instead of cramming into one line.

The Windows taskbar was showing a black square because no default window
icon was set at runtime. Added default_window_icon() call in the Tauri
builder using the app's icon.png.

Also adds vitest test infrastructure with tests verifying both fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:53:30 +00:00
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
58 changed files with 4174 additions and 581 deletions

View File

@@ -22,6 +22,24 @@ jobs:
steps:
- name: Checkout
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
run: |
@@ -80,12 +98,12 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="build-$(echo ${{ gitea.sha }} | cut -c1-7)"
TAG="v${{ steps.version.outputs.VERSION }}"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-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
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}"
@@ -109,6 +127,25 @@ jobs:
steps:
- name: Checkout
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
run: |
@@ -155,8 +192,7 @@ jobs:
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
if exist node_modules rmdir /s /q node_modules
if exist package-lock.json del package-lock.json
npm install
npm ci
- name: Build frontend
working-directory: ./app
@@ -186,9 +222,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set "TAG=build-win-%COMMIT_SHA:~0,7%"
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
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
:found
echo Release ID: %RELEASE_ID%

View File

@@ -1,3 +1,103 @@
# 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:
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **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)

1185
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -25,13 +27,17 @@
"devDependencies": {
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4",
"autoprefixer": "^10",
"jsdom": "^28.1.0",
"postcss": "^8",
"tailwindcss": "^4",
"typescript": "^5.7",
"vite": "^6"
"vite": "^6",
"vitest": "^4.0.18"
}
}

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

@@ -41,56 +41,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -404,6 +354,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -523,6 +479,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -537,12 +499,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -932,29 +888,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1018,6 +951,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"chrono",
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1333,8 +1276,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1344,9 +1289,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1649,6 +1596,23 @@ dependencies = [
"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]]
name = "hyper-util"
version = "0.1.20"
@@ -1718,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -1835,6 +1799,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1902,12 +1879,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1937,30 +1908,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2153,6 +2100,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2232,6 +2185,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2247,7 +2210,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -2550,12 +2513,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -2839,6 +2796,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2853,21 +2823,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2976,6 +2931,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -2985,6 +2949,61 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.44"
@@ -3025,6 +3044,16 @@ dependencies = [
"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]]
name = "rand_chacha"
version = "0.2.2"
@@ -3045,6 +3074,16 @@ dependencies = [
"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]]
name = "rand_core"
version = "0.5.1"
@@ -3063,6 +3102,15 @@ dependencies = [
"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]]
name = "rand_hc"
version = "0.2.0"
@@ -3165,6 +3213,44 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "reqwest"
version = "0.13.2"
@@ -3223,6 +3309,26 @@ dependencies = [
"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]]
name = "rustc_version"
version = "0.4.1"
@@ -3245,6 +3351,41 @@ dependencies = [
"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]]
name = "rustversion"
version = "1.0.22"
@@ -3709,6 +3850,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3860,6 +4007,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -3873,7 +4021,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -3927,7 +4075,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4258,6 +4406,21 @@ dependencies = [
"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]]
name = "tokio"
version = "1.49.0"
@@ -4286,6 +4449,16 @@ dependencies = [
"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]]
name = "tokio-util"
version = "0.7.18"
@@ -4487,7 +4660,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -4500,10 +4673,11 @@ dependencies = [
"bollard",
"chrono",
"dirs",
"env_logger",
"fern",
"futures-util",
"keyring",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
"tar",
@@ -4512,7 +4686,6 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-store",
"thiserror 2.0.18",
"tokio",
"uuid",
]
@@ -4605,6 +4778,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4642,12 +4821,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"
@@ -4857,6 +5030,16 @@ dependencies = [
"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]]
name = "webkit2gtk"
version = "2.0.2"
@@ -4901,6 +5084,15 @@ dependencies = [
"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]]
name = "webview2-com"
version = "0.38.2"
@@ -5131,6 +5323,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.59.0"

View File

@@ -12,7 +12,7 @@ name = "triple-c"
path = "src/main.rs"
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["image-png", "image-ico"] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
@@ -24,11 +24,11 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
dirs = "6"
log = "0.4"
env_logger = "0.11"
fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[build-dependencies]
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 settings_commands;
pub mod terminal_commands;
pub mod update_commands;

View File

@@ -1,10 +1,48 @@
use tauri::State;
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::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]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list())
@@ -13,10 +51,27 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
#[tauri::command]
pub async fn add_project(
name: String,
path: String,
paths: Vec<ProjectPath>,
state: State<'_, AppState>,
) -> 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)
}
@@ -28,13 +83,16 @@ pub async fn remove_project(
// Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await;
}
}
// Close any exec sessions
state.exec_manager.close_all_sessions().await;
// Clean up keychain secrets for this project
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)
}
@@ -44,6 +102,7 @@ pub async fn update_project(
project: Project,
state: State<'_, AppState>,
) -> Result<Project, String> {
store_secrets_for_project(&project)?;
state.projects_store.update(project)
}
@@ -57,18 +116,17 @@ pub async fn start_project_container(
.get(&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
let settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// Get API key only if auth mode requires it
let api_key = match project.auth_mode {
AuthMode::ApiKey => {
let key = secure::get_api_key()?
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
Some(key)
}
AuthMode::Login => {
let api_key: Option<String> = match project.auth_mode {
AuthMode::Anthropic => {
None
}
AuthMode::Bedrock => {
@@ -85,39 +143,54 @@ pub async fn start_project_container(
// Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
// Ensure image exists
if !docker::image_exists(&image_name).await? {
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
}
// Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async {
// Ensure image exists
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());
// 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();
// 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? {
// Check if docker socket mount matches the current project setting.
// If the user toggled "Allow container spawning" after the container was
// created, we need to recreate the container for the mount change to take
// effect.
let has_socket = docker::container_has_docker_socket(&existing_id).await.unwrap_or(false);
if has_socket != project.allow_docker_access {
log::info!(
"Docker socket mismatch (container has_socket={}, project wants={}), recreating container",
has_socket, project.allow_docker_access
);
// Safe to remove and recreate: the claude config named volume is
// keyed by project ID (not container ID) so it persists across
// container recreation. Bind mounts (workspace, SSH, AWS) are
// host paths and are unaffected.
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
// 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,
api_key.as_deref(),
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
)
.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(),
&settings.global_custom_env_vars,
).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(),
@@ -125,29 +198,25 @@ pub async fn start_project_container(
&image_name,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
).await?;
docker::start_container(&new_id).await?;
new_id
} else {
// Start existing container as-is
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
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);
}
let container_id = result?;
// Update project with container info using granular methods (Issue 14: TOCTOU)
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
@@ -170,7 +239,7 @@ pub async fn stop_project_container(
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// 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?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
@@ -191,7 +260,7 @@ pub async fn rebuild_project_container(
// Remove existing container
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;
docker::remove_container(container_id).await?;
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,109 @@ use bollard::container::{
};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project};
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
/// Compute a fingerprint for the API key so we can detect when it changes
/// without storing the actual key in Docker labels.
fn compute_api_key_fingerprint(api_key: Option<&str>) -> String {
match api_key {
Some(key) => {
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
None => String::new(),
}
}
/// 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 custom environment variables.
/// Per-project variables override global variables with the same key.
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
for ev in global {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
for ev in project {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
merged.into_values().collect()
}
/// 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> {
let docker = get_docker()?;
@@ -45,12 +145,17 @@ pub async fn create_container(
image_name: &str,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
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
#[cfg(unix)]
{
@@ -150,24 +255,54 @@ pub async fn create_container(
}
}
let mut mounts = vec![
// Project directory -> /workspace
Mount {
target: Some("/workspace".to_string()),
source: Some(project.path.clone()),
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
for env_var in &merged_env {
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 {
log::warn!("Skipping reserved env var: {}", key);
continue;
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
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),
read_only: Some(false),
..Default::default()
},
// Named volume for claude config persistence
Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
},
];
});
}
// Named volume for claude config persistence
mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
if let Some(ref ssh_path) = project.ssh_key_path {
@@ -212,9 +347,17 @@ pub async fn create_container(
// Docker socket (only if allowed)
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 {
target: Some("/var/run/docker.sock".to_string()),
source: Some(docker_socket_path.to_string()),
source: Some(mount_source),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
@@ -225,18 +368,29 @@ pub async fn create_container(
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
labels.insert("triple-c.api-key-fingerprint".to_string(), compute_api_key_fingerprint(api_key));
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 {
mounts: Some(mounts),
..Default::default()
};
let working_dir = if project.paths.len() == 1 {
format!("/workspace/{}", project.paths[0].mount_name)
} else {
"/workspace".to_string()
};
let config = Config {
image: Some(image_name.to_string()),
hostname: Some("triple-c".to_string()),
env: Some(env_vars),
labels: Some(labels),
working_dir: Some("/workspace".to_string()),
working_dir: Some(working_dir),
host_config: Some(host_config),
tty: Some(true),
..Default::default()
@@ -276,10 +430,15 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
pub async fn remove_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
log::info!(
"Removing container {} (v=false: named volumes such as claude config are preserved)",
container_id
);
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
v: false, // preserve named volumes (claude config)
force: true,
..Default::default()
}),
@@ -288,25 +447,171 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e))
}
/// Check whether an existing container has docker socket mounted.
pub async fn container_has_docker_socket(container_id: &str) -> Result<bool, String> {
/// 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,
api_key: Option<&str>,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
) -> 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 has_socket = info
.host_config
.and_then(|hc| hc.mounts)
.map(|mounts| {
mounts.iter().any(|m| {
m.target.as_deref() == Some("/var/run/docker.sock")
})
})
.unwrap_or(false);
let labels = info
.config
.as_ref()
.and_then(|c| c.labels.as_ref());
Ok(has_socket)
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);
}
}
// ── API key fingerprint ─────────────────────────────────────────────
let expected_api_key_fp = compute_api_key_fingerprint(api_key);
let container_api_key_fp = get_label("triple-c.api-key-fingerprint").unwrap_or_default();
if container_api_key_fp != expected_api_key_fp {
log::info!("API key fingerprint mismatch, triggering recreation");
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 merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env);
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> {

View File

@@ -9,6 +9,7 @@ use super::client::get_docker;
pub struct ExecSession {
pub exec_id: String,
pub container_id: String,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>,
}
@@ -140,6 +141,7 @@ impl ExecSessionManager {
let session = ExecSession {
exec_id,
container_id: container_id.to_string(),
input_tx,
shutdown_tx,
};
@@ -161,11 +163,26 @@ impl ExecSessionManager {
}
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.resize(cols, rows).await
// Clone the exec_id under the lock, then drop the lock before the
// async Docker API call to avoid holding the mutex across await.
let exec_id = {
let sessions = self.sessions.lock().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) {
@@ -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) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {

View File

@@ -1,11 +1,13 @@
mod commands;
mod docker;
mod logging;
mod models;
mod storage;
use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use tauri::Manager;
pub struct AppState {
pub projects_store: ProjectsStore,
@@ -14,17 +16,53 @@ pub struct AppState {
}
pub fn run() {
env_logger::init();
logging::init();
let projects_store = match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
};
let settings_store = match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
};
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(AppState {
projects_store: ProjectsStore::new(),
settings_store: SettingsStore::new(),
projects_store,
settings_store,
exec_manager: ExecSessionManager::new(),
})
.setup(|app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_icon(icon);
}
}
Err(e) => {
log::error!("Failed to load window icon: {}", e);
}
}
Ok(())
})
.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![
// Docker
commands::docker_commands::check_docker,
@@ -54,6 +92,9 @@ pub fn run() {
commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,73 @@
use std::fs;
use std::path::PathBuf;
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
fn log_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
}
/// Initialise logging to both stderr and a log file in the app data directory.
///
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
/// A panic hook is also installed so that unexpected crashes are captured in the
/// same log file before the process exits.
pub fn init() {
let log_file_path = log_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
let path = dir.join("triple-c.log");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()
.map(|file| (path, file))
});
let mut dispatch = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{} {} {}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.target(),
message
))
})
.level(log::LevelFilter::Info)
.chain(std::io::stderr());
if let Some((_path, file)) = &log_file_path {
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
}
if let Err(e) = dispatch.apply() {
eprintln!("Failed to initialise logger: {}", e);
}
// Install a panic hook that writes to the log file so crashes are captured.
let crash_log_dir = log_dir();
std::panic::set_hook(Box::new(move |info| {
let msg = format!(
"[{} PANIC] {}\nBacktrace:\n{:?}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
info,
std::backtrace::Backtrace::force_capture(),
);
eprintln!("{}", msg);
if let Some(ref dir) = crash_log_dir {
let crash_path = dir.join("triple-c.log");
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&crash_path)
.and_then(|mut f| {
use std::io::Write;
writeln!(f, "{}", msg)
});
}
}));
if let Some((ref path, _)) = log_file_path {
log::info!("Logging to {}", path.display());
}
}

View File

@@ -1,5 +1,15 @@
use serde::{Deserialize, Serialize};
use super::project::EnvVar;
fn default_true() -> bool {
true
}
fn default_global_instructions() -> Option<String> {
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ImageSource {
@@ -50,6 +60,14 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
pub global_custom_env_vars: Vec<EnvVar>,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub dismissed_update_version: Option<String>,
}
impl Default for AppSettings {
@@ -62,6 +80,10 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
dismissed_update_version: None,
}
}
}

View File

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

View File

@@ -1,19 +1,36 @@
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)]
pub struct Project {
pub id: String,
pub name: String,
pub path: String,
pub paths: Vec<ProjectPath>,
pub container_id: Option<String>,
pub status: ProjectStatus,
pub auth_mode: AuthMode,
pub bedrock_config: Option<BedrockConfig>,
pub allow_docker_access: bool,
pub ssh_key_path: Option<String>,
#[serde(skip_serializing)]
pub git_token: Option<String>,
pub git_user_name: 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 updated_at: String,
}
@@ -29,20 +46,21 @@ pub enum ProjectStatus {
}
/// How the project authenticates with Claude.
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
/// - `ApiKey`: Uses the API key stored in the OS keychain
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
/// persisted in the config volume)
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Login,
ApiKey,
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")]
Anthropic,
Bedrock,
}
impl Default for AuthMode {
fn default() -> Self {
Self::Login
Self::Anthropic
}
}
@@ -66,22 +84,26 @@ impl Default for BedrockAuthMethod {
pub struct BedrockConfig {
pub auth_method: BedrockAuthMethod,
pub aws_region: String,
#[serde(skip_serializing)]
pub aws_access_key_id: Option<String>,
#[serde(skip_serializing)]
pub aws_secret_access_key: Option<String>,
#[serde(skip_serializing)]
pub aws_session_token: Option<String>,
pub aws_profile: Option<String>,
#[serde(skip_serializing)]
pub aws_bearer_token: Option<String>,
pub model_id: Option<String>,
pub disable_prompt_caching: bool,
}
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();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
path,
paths,
container_id: None,
status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(),
@@ -91,6 +113,8 @@ impl Project {
git_token: None,
git_user_name: None,
git_user_email: None,
custom_env_vars: Vec::new(),
claude_instructions: None,
created_at: now.clone(),
updated_at: now,
}
@@ -99,4 +123,29 @@ impl Project {
pub fn container_name(&self) -> String {
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 {
pub fn new() -> Self {
pub fn new() -> Result<Self, String> {
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");
fs::create_dir_all(&data_dir).ok();
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) {
Ok(data) => match serde_json::from_str(&data) {
Ok(parsed) => parsed,
Err(e) => {
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
// Back up the corrupted file
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted projects.json: {}", be);
Ok(data) => {
// First try to parse as Vec<Value> to run migration
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
Ok(raw_values) => {
let mut migrated = false;
let migrated_values: Vec<serde_json::Value> = raw_values
.into_iter()
.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) => {
log::error!("Failed to read projects.json: {}", e);
Vec::new()
(Vec::new(), false)
}
}
} else {
Vec::new()
(Vec::new(), false)
};
Self {
let store = Self {
projects: Mutex::new(projects),
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>> {

View File

@@ -36,3 +36,49 @@ pub fn has_api_key() -> Result<bool, String> {
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 {
pub fn new() -> Self {
pub fn new() -> Result<Self, String> {
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");
fs::create_dir_all(&data_dir).ok();
@@ -41,10 +41,10 @@ impl SettingsStore {
AppSettings::default()
};
Self {
Ok(Self {
settings: Mutex::new(settings),
file_path,
}
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C",
"version": "0.1.0",
"identifier": "com.triple-c.app",
"identifier": "com.triple-c.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
@@ -22,7 +22,7 @@
}
],
"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": {

View File

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

@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import Sidebar from "./Sidebar";
// Mock zustand store
vi.mock("../../store/appState", () => ({
useAppState: vi.fn((selector) =>
selector({
sidebarView: "projects",
setSidebarView: vi.fn(),
})
),
}));
// Mock child components to isolate Sidebar layout testing
vi.mock("../projects/ProjectList", () => ({
default: () => <div data-testid="project-list">ProjectList</div>,
}));
vi.mock("../settings/SettingsPanel", () => ({
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
}));
describe("Sidebar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the sidebar with content area", () => {
render(<Sidebar />);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("content area has min-w-0 to prevent flex overflow", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
expect(contentArea).not.toBeNull();
expect(contentArea!.className).toContain("min-w-0");
});
it("content area has overflow-x-hidden to prevent horizontal scroll", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
expect(contentArea).not.toBeNull();
expect(contentArea!.className).toContain("overflow-x-hidden");
});
it("sidebar outer container has overflow-hidden", () => {
const { container } = render(<Sidebar />);
const sidebar = container.firstElementChild;
expect(sidebar).not.toBeNull();
expect(sidebar!.className).toContain("overflow-hidden");
});
});

View File

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

View File

@@ -1,11 +1,14 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
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;
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>
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>

View File

@@ -1,19 +1,62 @@
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
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 (
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex-1 overflow-x-auto">
<TerminalTabs />
<>
<div 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 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 className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]">
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
</div>
</div>
{showUpdateDialog && updateInfo && (
<UpdateDialog
updateInfo={updateInfo}
currentVersion={appVersion}
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 { useProjects } from "../../hooks/useProjects";
import type { ProjectPath } from "../../lib/types";
interface Props {
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) {
const { add } = useProjects();
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 [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 });
if (typeof selected === "string") {
setPath(selected);
if (!name) {
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
setName(parts[parts.length - 1]);
const basename = basenameFromPath(selected);
const entries = [...pathEntries];
entries[index] = {
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 () => {
if (!name.trim() || !path.trim()) {
setError("Name and path are required");
const updateEntry = (
index: number,
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;
}
setLoading(true);
setError(null);
try {
await add(name.trim(), path.trim());
await add(name.trim(), validPaths);
onClose();
} catch (e) {
setError(String(e));
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
<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] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-project"
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
/>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Path
</label>
<div className="flex gap-2 mb-4">
<form onSubmit={handleSubmit}>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Name
</label>
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/path/to/project"
className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
ref={nameInputRef}
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)]"
/>
<button
onClick={handleBrowse}
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
>
Browse
</button>
</div>
{error && (
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
)}
<label className="block text-sm text-[var(--text-secondary)] mb-1">
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">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{loading ? "Adding..." : "Add Project"}
</button>
</div>
{error && (
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
type="submit"
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>
);

View File

@@ -0,0 +1,80 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface Props {
instructions: string;
disabled: boolean;
onSave: (instructions: string) => Promise<void>;
onClose: () => void;
}
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
const [instructions, setInstructions] = useState(initial);
const overlayRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.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 handleBlur = async () => {
try { await onSave(instructions); } catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
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-[40rem] shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change Claude instructions.
</div>
)}
<textarea
ref={textareaRef}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={handleBlur}
placeholder="Enter instructions for Claude Code in this project's container..."
disabled={disabled}
rows={14}
className="w-full 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)] disabled:opacity-50 resize-y font-mono"
/>
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { EnvVar } from "../../lib/types";
interface Props {
envVars: EnvVar[];
disabled: boolean;
onSave: (vars: EnvVar[]) => Promise<void>;
onClose: () => void;
}
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
const [vars, setVars] = useState<EnvVar[]>(initial);
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 updateVar = (index: number, field: keyof EnvVar, value: string) => {
const updated = [...vars];
updated[index] = { ...updated[index], [field]: value };
setVars(updated);
};
const removeVar = async (index: number) => {
const updated = vars.filter((_, i) => i !== index);
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
};
const addVar = async () => {
const updated = [...vars, { key: "", value: "" }];
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add environment variable:", err);
}
};
const handleBlur = async () => {
try { await onSave(vars); } catch (err) {
console.error("Failed to update environment variables:", err);
}
};
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-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change environment variables.
</div>
)}
<div className="space-y-2 mb-4">
{vars.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
)}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={handleBlur}
placeholder="KEY"
disabled={disabled}
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={handleBlur}
placeholder="value"
disabled={disabled}
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<button
onClick={() => removeVar(i)}
disabled={disabled}
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addVar}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add variable
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import ProjectCard from "./ProjectCard";
import type { Project } from "../../lib/types";
// Mock Tauri dialog plugin
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
// Mock hooks
const mockUpdate = vi.fn();
const mockStart = vi.fn();
const mockStop = vi.fn();
const mockRebuild = vi.fn();
const mockRemove = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
start: mockStart,
stop: mockStop,
rebuild: mockRebuild,
remove: mockRemove,
update: mockUpdate,
}),
}));
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({
open: vi.fn(),
}),
}));
let mockSelectedProjectId: string | null = null;
vi.mock("../../store/appState", () => ({
useAppState: vi.fn((selector) =>
selector({
selectedProjectId: mockSelectedProjectId,
setSelectedProject: vi.fn(),
})
),
}));
const mockProject: Project = {
id: "test-1",
name: "Test Project",
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null,
status: "stopped",
auth_mode: "anthropic",
bedrock_config: null,
allow_docker_access: false,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
claude_instructions: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("ProjectCard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectedProjectId = null;
});
it("renders project name and path", () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
});
it("card root has min-w-0 and overflow-hidden to contain content", () => {
const { container } = render(<ProjectCard project={mockProject} />);
const card = container.firstElementChild;
expect(card).not.toBeNull();
expect(card!.className).toContain("min-w-0");
expect(card!.className).toContain("overflow-hidden");
});
describe("when selected and showing config", () => {
beforeEach(() => {
mockSelectedProjectId = "test-1";
});
it("expanded area has min-w-0 and overflow-hidden", () => {
const { container } = render(<ProjectCard project={mockProject} />);
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
const expandedSection = container.querySelector(".ml-4.mt-2");
expect(expandedSection).not.toBeNull();
expect(expandedSection!.className).toContain("min-w-0");
expect(expandedSection!.className).toContain("overflow-hidden");
});
it("folder path inputs use min-w-0 to allow shrinking", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button to show config panel
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// After config is shown, check the folder host_path input has min-w-0
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
expect(hostPathInputs.length).toBeGreaterThan(0);
expect(hostPathInputs[0].className).toContain("min-w-0");
});
it("config panel container has overflow-hidden", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// The config panel has border-t and overflow containment classes
const allDivs = container.querySelectorAll("div");
const configPanel = Array.from(allDivs).find(
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
);
expect(configPanel).toBeDefined();
expect(configPanel!.className).toContain("overflow-hidden");
});
});
});

View File

@@ -1,24 +1,65 @@
import { useState } from "react";
import { useState, useEffect } from "react";
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 { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
interface Props {
project: Project;
}
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 { open: openTerminal } = useTerminal();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const isSelected = selectedProjectId === project.id;
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 () => {
setLoading(true);
setError(null);
@@ -79,7 +120,9 @@ export default function ProjectCard({ project }: Props) {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, ...patch } });
} catch {}
} catch (err) {
console.error("Failed to update Bedrock config:", err);
}
};
const handleBrowseSSH = async () => {
@@ -93,6 +136,102 @@ 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 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 = {
stopped: "bg-[var(--text-secondary)]",
starting: "bg-[var(--warning)]",
@@ -104,7 +243,7 @@ export default function ProjectCard({ project }: Props) {
return (
<div
onClick={() => setSelectedProject(project.id)}
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
isSelected
? "bg-[var(--bg-tertiary)]"
: "hover:bg-[var(--bg-tertiary)]"
@@ -114,36 +253,29 @@ export default function ProjectCard({ project }: Props) {
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
</div>
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
{project.path}
<div className="mt-0.5 ml-4 space-y-0.5">
{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>
{isSelected && (
<div className="mt-2 ml-4 space-y-2">
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Auth mode selector */}
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "login"
project.auth_mode === "anthropic"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
/login
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "api_key"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
API key
Anthropic
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
@@ -159,7 +291,7 @@ export default function ProjectCard({ project }: Props) {
</div>
{/* Action buttons */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-wrap">
{isStopped ? (
<>
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
@@ -202,16 +334,109 @@ export default function ProjectCard({ project }: Props) {
{/* Config panel */}
{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)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{!isStopped && (
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change settings.
</div>
)}
{/* 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="mb-1">
<div className="flex gap-1 items-center min-w-0">
<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 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] 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="flex-shrink-0 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>
{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="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
)}
</div>
<div className="flex gap-1 items-center mt-0.5 min-w-0">
<span className="text-xs text-[var(--text-secondary)]">/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="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
</div>
</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 */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<div className="flex gap-1">
<input
value={project.ssh_key_path ?? ""}
onChange={async (e) => {
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
}}
value={sshKeyPath}
onChange={(e) => setSshKeyPath(e.target.value)}
onBlur={handleSshKeyPathBlur}
placeholder="~/.ssh"
disabled={!isStopped}
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
@@ -230,10 +455,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<input
value={project.git_user_name ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
}}
value={gitName}
onChange={(e) => setGitName(e.target.value)}
onBlur={handleGitNameBlur}
placeholder="Your Name"
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
@@ -244,10 +468,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<input
value={project.git_user_email ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
}}
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
onBlur={handleGitEmailBlur}
placeholder="you@example.com"
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
@@ -259,10 +482,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<input
type="password"
value={project.git_token ?? ""}
onChange={async (e) => {
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
}}
value={gitToken}
onChange={(e) => setGitToken(e.target.value)}
onBlur={handleGitTokenBlur}
placeholder="ghp_..."
disabled={!isStopped}
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
@@ -274,7 +496,9 @@ export default function ProjectCard({ project }: Props) {
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<button
onClick={async () => {
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {}
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
console.error("Failed to update Docker access setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
@@ -287,6 +511,32 @@ export default function ProjectCard({ project }: Props) {
</button>
</div>
{/* Environment Variables */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
</label>
<button
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Claude Instructions */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Instructions{claudeInstructions ? " (set)" : ""}
</label>
<button
onClick={() => setShowClaudeInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Bedrock config */}
{project.auth_mode === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig;
@@ -318,8 +568,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
<input
value={bc.aws_region}
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })}
value={bedrockRegion}
onChange={(e) => setBedrockRegion(e.target.value)}
onBlur={handleBedrockRegionBlur}
placeholder="us-east-1"
disabled={!isStopped}
className={inputCls}
@@ -332,8 +583,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
<input
value={bc.aws_access_key_id ?? ""}
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })}
value={bedrockAccessKeyId}
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
onBlur={handleBedrockAccessKeyIdBlur}
placeholder="AKIA..."
disabled={!isStopped}
className={inputCls}
@@ -343,8 +595,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
<input
type="password"
value={bc.aws_secret_access_key ?? ""}
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })}
value={bedrockSecretKey}
onChange={(e) => setBedrockSecretKey(e.target.value)}
onBlur={handleBedrockSecretKeyBlur}
disabled={!isStopped}
className={inputCls}
/>
@@ -353,8 +606,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
<input
type="password"
value={bc.aws_session_token ?? ""}
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })}
value={bedrockSessionToken}
onChange={(e) => setBedrockSessionToken(e.target.value)}
onBlur={handleBedrockSessionTokenBlur}
disabled={!isStopped}
className={inputCls}
/>
@@ -367,8 +621,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
<input
value={bc.aws_profile ?? ""}
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })}
value={bedrockProfile}
onChange={(e) => setBedrockProfile(e.target.value)}
onBlur={handleBedrockProfileBlur}
placeholder="default"
disabled={!isStopped}
className={inputCls}
@@ -382,8 +637,9 @@ export default function ProjectCard({ project }: Props) {
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
<input
type="password"
value={bc.aws_bearer_token ?? ""}
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })}
value={bedrockBearerToken}
onChange={(e) => setBedrockBearerToken(e.target.value)}
onBlur={handleBedrockBearerTokenBlur}
disabled={!isStopped}
className={inputCls}
/>
@@ -394,8 +650,9 @@ export default function ProjectCard({ project }: Props) {
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
<input
value={bc.model_id ?? ""}
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })}
value={bedrockModelId}
onChange={(e) => setBedrockModelId(e.target.value)}
onBlur={handleBedrockModelIdBlur}
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
disabled={!isStopped}
className={inputCls}
@@ -412,6 +669,30 @@ export default function ProjectCard({ project }: Props) {
{error && (
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={envVars}
disabled={!isStopped}
onSave={async (vars) => {
setEnvVars(vars);
await update({ ...project, custom_env_vars: vars });
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
{showClaudeInstructionsModal && (
<ClaudeInstructionsModal
instructions={claudeInstructions}
disabled={!isStopped}
onSave={async (instructions) => {
setClaudeInstructions(instructions);
await update({ ...project, claude_instructions: instructions || null });
}}
onClose={() => setShowClaudeInstructionsModal(false)}
/>
)}
</div>
);
}

View File

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

View File

@@ -51,6 +51,7 @@ export default function AwsSettings() {
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global AWS defaults for Bedrock projects. Per-project settings override these.
Changes here require a container rebuild to take effect.
</p>
{/* AWS Config Path */}
@@ -83,7 +84,7 @@ export default function AwsSettings() {
<select
value={globalAws.aws_profile ?? ""}
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>
{profiles.map((p) => (

View File

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

View File

@@ -1,8 +1,42 @@
import { useState, useEffect } from "react";
import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import EnvVarsModal from "../projects/EnvVarsModal";
import type { EnvVar } from "../../lib/types";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
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 (
<div className="p-4 space-y-6">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
@@ -11,6 +45,104 @@ export default function SettingsPanel() {
<ApiKeyInput />
<DockerSettings />
<AwsSettings />
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-1">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>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalInstructions ? "Configured" : "Not set"}
</span>
<button
onClick={() => setShowInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Global Environment Variables */}
<div>
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Applied to all project containers. Per-project variables override global ones with the same key.
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
</span>
<button
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</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>
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
disabled={false}
onSave={async (instructions) => {
setGlobalInstructions(instructions);
if (appSettings) {
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
}
}}
onClose={() => setShowInstructionsModal(false)}
/>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={globalEnvVars}
disabled={false}
onSave={async (vars) => {
setGlobalEnvVars(vars);
if (appSettings) {
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
}
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
</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 { 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 {
sessionId: string;
active: boolean;
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal();
useEffect(() => {
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current);
// Try WebGL renderer, fall back silently
try {
const webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
} catch {
// WebGL not available, canvas renderer is fine
}
// WebGL addon is loaded/disposed dynamically in the active effect
// to avoid exhausting the browser's limited WebGL context pool.
fitAddon.fit();
termRef.current = term;
@@ -88,81 +79,84 @@ export default function TerminalView({ sessionId, active }: Props) {
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
let unlistenOutput: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
let aborted = false;
onOutput(sessionId, (data) => {
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
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) => {
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");
}).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(() => {
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
if (resizeRafId !== null) return;
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);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
aborted = true;
inputDisposable.dispose();
unlistenOutput?.();
unlistenExit?.();
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose();
};
}, [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(() => {
if (active && fitRef.current && termRef.current) {
fitRef.current.fit();
termRef.current.focus();
const term = termRef.current;
if (!term) return;
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]);
@@ -170,7 +164,7 @@ export default function TerminalView({ sessionId, active }: Props) {
<div
ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "4px" }}
style={{ padding: "8px" }}
/>
);
}

View File

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

View File

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

View File

@@ -1,10 +1,18 @@
import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
import type { AppSettings } from "../lib/types";
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 () => {
try {

View File

@@ -1,11 +1,20 @@
import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
export function useTerminal() {
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
useAppState();
useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
addSession: s.addSession,
removeSession: s.removeSession,
setActiveSession: s.setActiveSession,
}))
);
const open = useCallback(
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;
--warning: #d29922;
--error: #f85149;
color-scheme: dark;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;

View File

@@ -1,5 +1,5 @@
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
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -12,8 +12,8 @@ export const listSiblingContainers = () =>
// Projects
export const listProjects = () => invoke<Project[]>("list_projects");
export const addProject = (name: string, path: string) =>
invoke<Project>("add_project", { name, path });
export const addProject = (name: string, paths: ProjectPath[]) =>
invoke<Project>("add_project", { name, paths });
export const removeProject = (projectId: string) =>
invoke<void>("remove_project", { projectId });
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 });
export const closeTerminalSession = (sessionId: string) =>
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 {
id: string;
name: string;
path: string;
paths: ProjectPath[];
container_id: string | null;
status: ProjectStatus;
auth_mode: AuthMode;
@@ -11,6 +21,8 @@ export interface Project {
git_token: string | null;
git_user_name: string | null;
git_user_email: string | null;
custom_env_vars: EnvVar[];
claude_instructions: string | null;
created_at: string;
updated_at: string;
}
@@ -22,7 +34,7 @@ export type ProjectStatus =
| "stopping"
| "error";
export type AuthMode = "login" | "api_key" | "bedrock";
export type AuthMode = "anthropic" | "bedrock";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -75,4 +87,23 @@ export interface AppSettings {
image_source: ImageSource;
custom_image_name: string | null;
global_aws: GlobalAwsSettings;
global_claude_instructions: string | null;
global_custom_env_vars: EnvVar[];
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 type { Project, TerminalSession, AppSettings } from "../lib/types";
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
interface AppState {
// Projects
@@ -30,6 +30,12 @@ interface AppState {
// App settings
appSettings: AppSettings | null;
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) => ({
@@ -85,4 +91,10 @@ export const useAppState = create<AppState>((set) => ({
// App settings
appSettings: null,
setAppSettings: (settings) => set({ appSettings: settings }),
// Update info
updateInfo: null,
setUpdateInfo: (info) => set({ updateInfo: info }),
appVersion: "",
setAppVersion: (version) => set({ appVersion: version }),
}));

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
describe("Window icon configuration", () => {
const srcTauriDir = resolve(__dirname, "../../src-tauri");
it("lib.rs sets window icon using set_icon in setup hook", () => {
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
expect(libRs).toContain("set_icon");
expect(libRs).toContain("icon.png");
});
it("Cargo.toml enables image-png feature for icon loading", () => {
const cargoToml = readFileSync(resolve(srcTauriDir, "Cargo.toml"), "utf-8");
expect(cargoToml).toContain("image-png");
});
it("icon.png exists in the icons directory", () => {
const iconPath = resolve(srcTauriDir, "icons/icon.png");
expect(existsSync(iconPath)).toBe(true);
});
it("icon.ico exists in the icons directory for Windows", () => {
const icoPath = resolve(srcTauriDir, "icons/icon.ico");
expect(existsSync(icoPath)).toBe(true);
});
it("tauri.conf.json includes icon.ico in bundle icons", () => {
const config = JSON.parse(
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
);
expect(config.bundle.icon).toContain("icons/icon.ico");
expect(config.bundle.icon).toContain("icons/icon.png");
});
});

1
app/src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -17,5 +17,6 @@
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

11
app/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
});

View File

@@ -50,9 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
&& rm -rf /var/lib/apt/lists/*
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
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/*
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
RUN ARCH=$(uname -m) && \
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 ─────────────────────────────────────
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
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 \
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
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}@gitlab.com" >> "$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
fi
# ── Git user config ──────────────────────────────────────────────────────────
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
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
# ── Docker socket permissions ────────────────────────────────────────────────