Compare commits
19 Commits
build-win-
...
v0.1.44
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff270ebfe | |||
| 5a59fdb64b | |||
| 1ce5151e59 | |||
| 66ddc182c9 | |||
| 1524ec4a98 | |||
| 4721950eae | |||
| fba4b9442c | |||
| 48f0e2f64c | |||
| 7e1cc92aa4 | |||
| 854f59a95a | |||
| 265b365f0b | |||
| 03e0590631 | |||
| 82f159d2a9 | |||
| a03bdccdc7 | |||
| 82c487184a | |||
| 96f8acc40d | |||
| b77b9679b1 | |||
| 0a4f207556 | |||
| 839dd9f105 |
@@ -22,6 +22,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
VERSION="0.1.${COMMIT_COUNT}"
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed version: ${VERSION}"
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||||
|
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||||
|
echo "Patched version to ${VERSION}"
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -80,12 +98,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="build-$(echo ${{ gitea.sha }} | cut -c1-7)"
|
TAG="v${{ steps.version.outputs.VERSION }}"
|
||||||
# Create release
|
# Create release
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Linux Build ${TAG}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
@@ -109,6 +127,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
|
||||||
|
set "VERSION=0.1.%COMMIT_COUNT%"
|
||||||
|
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
||||||
|
echo Computed version: %VERSION%
|
||||||
|
|
||||||
|
- name: Set app version
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$version = "${{ steps.version.outputs.VERSION }}"
|
||||||
|
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||||
|
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||||
|
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||||
|
Write-Host "Patched version to $version"
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
run: |
|
run: |
|
||||||
@@ -155,8 +192,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
if exist node_modules rmdir /s /q node_modules
|
if exist node_modules rmdir /s /q node_modules
|
||||||
if exist package-lock.json del package-lock.json
|
npm ci
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
@@ -186,9 +222,9 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
set "TAG=build-win-%COMMIT_SHA:~0,7%"
|
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
||||||
echo Creating release %TAG%...
|
echo Creating release %TAG%...
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Windows Build %TAG%\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
||||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
||||||
:found
|
:found
|
||||||
echo Release ID: %RELEASE_ID%
|
echo Release ID: %RELEASE_ID%
|
||||||
|
|||||||
103
Triple-C.md
@@ -1,3 +1,104 @@
|
|||||||
# Triple-C (Claude-Code-Container)
|
# Triple-C (Claude-Code-Container)
|
||||||
|
|
||||||
Triple C is a container intended to limit what files Claude Code has access to, so when you run with `--dangerously-skip-permissions` Claude only has access to files/projects you provide to it.
|
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||||
|
- **Backend**: Rust (Tauri v2 framework)
|
||||||
|
- **Terminal**: xterm.js with WebGL rendering
|
||||||
|
- **Docker API**: bollard (pure Rust Docker client)
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar (terminal tabs + Docker/Image status) │
|
||||||
|
├────────────┬────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ Main Content (terminal views) │
|
||||||
|
│ (25% w, │ │
|
||||||
|
│ responsive│ │
|
||||||
|
│ min/max) │ │
|
||||||
|
├────────────┴────────────────────────────────────────┤
|
||||||
|
│ StatusBar (project/terminal counts) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
|
||||||
|
1. **Create**: New container created with bind mounts, env vars, and labels
|
||||||
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
|
||||||
|
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
||||||
|
4. **Stop**: Container halted (filesystem persists in named volume)
|
||||||
|
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
||||||
|
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
| Target in Container | Source | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace` | Project directory | Bind | Read-write |
|
||||||
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
||||||
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
|
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||||
|
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
|
||||||
|
|
||||||
|
### Authentication Modes
|
||||||
|
|
||||||
|
Each project can independently use one of:
|
||||||
|
|
||||||
|
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
||||||
|
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
||||||
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||||
|
|
||||||
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
|
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||||
|
|
||||||
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
|
### Docker Socket Path
|
||||||
|
|
||||||
|
The socket path is OS-aware:
|
||||||
|
- **Linux/macOS**: `/var/run/docker.sock`
|
||||||
|
- **Windows**: `//./pipe/docker_engine`
|
||||||
|
|
||||||
|
Users can override this in Settings via the global `docker_socket_path` option.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
|
||||||
|
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
|
||||||
|
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
||||||
|
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||||
|
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
||||||
|
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
||||||
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
|
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
||||||
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
||||||
|
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
||||||
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
||||||
|
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
||||||
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
|
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
||||||
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
||||||
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
||||||
|
|
||||||
|
## CSS / Styling Notes
|
||||||
|
|
||||||
|
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
|
||||||
|
- All colors use CSS custom properties defined in `index.css` `:root`
|
||||||
|
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
|
||||||
|
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
|
||||||
|
|
||||||
|
## Container Image
|
||||||
|
|
||||||
|
**Base**: Ubuntu 24.04
|
||||||
|
|
||||||
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||||
|
|
||||||
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
1185
app/package-lock.json
generated
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -25,13 +27,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5.7",
|
"typescript": "^5.7",
|
||||||
"vite": "^6"
|
"vite": "^6",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
339
app/src-tauri/Cargo.lock
generated
@@ -404,6 +404,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -523,6 +529,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -1333,8 +1345,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1344,9 +1358,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1649,6 +1665,23 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1718,7 +1751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1835,6 +1868,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -2153,6 +2199,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2232,6 +2284,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -2247,7 +2309,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -2839,6 +2901,19 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"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]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -2976,6 +3051,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -2985,6 +3069,61 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@@ -3025,6 +3164,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3045,6 +3194,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3063,6 +3222,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3165,6 +3333,44 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3223,6 +3429,26 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3245,6 +3471,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3709,6 +3970,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3860,6 +4127,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -3873,7 +4141,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3927,7 +4195,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4258,6 +4526,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.49.0"
|
||||||
@@ -4286,6 +4569,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4487,7 +4780,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -4504,6 +4797,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tar",
|
"tar",
|
||||||
@@ -4512,7 +4806,6 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -4605,6 +4898,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4857,6 +5156,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -4901,6 +5210,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5131,6 +5449,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ name = "triple-c"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
@@ -24,11 +24,11 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "2"
|
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 41 KiB |
@@ -2,3 +2,4 @@ pub mod docker_commands;
|
|||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -1,10 +1,48 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, AuthMode, Project, ProjectStatus};
|
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Extract secret fields from a project and store them in the OS keychain.
|
||||||
|
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||||
|
if let Some(ref token) = project.git_token {
|
||||||
|
secure::store_project_secret(&project.id, "git-token", token)?;
|
||||||
|
}
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
if let Some(ref v) = bedrock.aws_access_key_id {
|
||||||
|
secure::store_project_secret(&project.id, "aws-access-key-id", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_secret_access_key {
|
||||||
|
secure::store_project_secret(&project.id, "aws-secret-access-key", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_session_token {
|
||||||
|
secure::store_project_secret(&project.id, "aws-session-token", v)?;
|
||||||
|
}
|
||||||
|
if let Some(ref v) = bedrock.aws_bearer_token {
|
||||||
|
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate secret fields on a project struct from the OS keychain.
|
||||||
|
fn load_secrets_for_project(project: &mut Project) {
|
||||||
|
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(ref mut bedrock) = project.bedrock_config {
|
||||||
|
bedrock.aws_access_key_id = secure::get_project_secret(&project.id, "aws-access-key-id")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_secret_access_key = secure::get_project_secret(&project.id, "aws-secret-access-key")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_session_token = secure::get_project_secret(&project.id, "aws-session-token")
|
||||||
|
.unwrap_or(None);
|
||||||
|
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
||||||
|
.unwrap_or(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||||
Ok(state.projects_store.list())
|
Ok(state.projects_store.list())
|
||||||
@@ -13,10 +51,27 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, S
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_project(
|
pub async fn add_project(
|
||||||
name: String,
|
name: String,
|
||||||
path: String,
|
paths: Vec<ProjectPath>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
let project = Project::new(name, path);
|
// Validate paths
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err("At least one folder path is required.".to_string());
|
||||||
|
}
|
||||||
|
let mut seen_names = std::collections::HashSet::new();
|
||||||
|
for p in &paths {
|
||||||
|
if p.mount_name.is_empty() {
|
||||||
|
return Err("Mount name cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
||||||
|
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
||||||
|
}
|
||||||
|
if !seen_names.insert(p.mount_name.clone()) {
|
||||||
|
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let project = Project::new(name, paths);
|
||||||
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.add(project)
|
state.projects_store.add(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,13 +83,16 @@ pub async fn remove_project(
|
|||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists
|
||||||
if let Some(project) = state.projects_store.get(&project_id) {
|
if let Some(project) = state.projects_store.get(&project_id) {
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
let _ = docker::stop_container(container_id).await;
|
let _ = docker::stop_container(container_id).await;
|
||||||
let _ = docker::remove_container(container_id).await;
|
let _ = docker::remove_container(container_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close any exec sessions
|
// Clean up keychain secrets for this project
|
||||||
state.exec_manager.close_all_sessions().await;
|
if let Err(e) = secure::delete_project_secrets(&project_id) {
|
||||||
|
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
state.projects_store.remove(&project_id)
|
state.projects_store.remove(&project_id)
|
||||||
}
|
}
|
||||||
@@ -44,6 +102,7 @@ pub async fn update_project(
|
|||||||
project: Project,
|
project: Project,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.update(project)
|
state.projects_store.update(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +116,10 @@ pub async fn start_project_container(
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
// Populate secret fields from the OS keychain so they are available
|
||||||
|
// in memory when building environment variables for the container.
|
||||||
|
load_secrets_for_project(&mut project);
|
||||||
|
|
||||||
// Load settings for image resolution and global AWS
|
// Load settings for image resolution and global AWS
|
||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
@@ -85,35 +148,53 @@ pub async fn start_project_container(
|
|||||||
// Update status to starting
|
// Update status to starting
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
|
|
||||||
// Ensure image exists
|
// Wrap container operations so that any failure resets status to Stopped.
|
||||||
if !docker::image_exists(&image_name).await? {
|
let result: Result<String, String> = async {
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
// Ensure image exists
|
||||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
if !docker::image_exists(&image_name).await? {
|
||||||
}
|
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||||
|
}
|
||||||
|
|
||||||
// Determine docker socket path
|
// Determine docker socket path
|
||||||
let docker_socket = settings.docker_socket_path
|
let docker_socket = settings.docker_socket_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| default_docker_socket());
|
.unwrap_or_else(|| default_docker_socket());
|
||||||
|
|
||||||
// AWS config path from global settings
|
// AWS config path from global settings
|
||||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||||
|
|
||||||
// Check for existing container
|
// Check for existing container
|
||||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||||
// Compare the running container's configuration (mounts, env vars)
|
let needs_recreation = docker::container_needs_recreation(
|
||||||
// against the current project settings. If anything changed (SSH key
|
&existing_id,
|
||||||
// path, git config, docker socket, etc.) we recreate the container.
|
&project,
|
||||||
// Safe to recreate: the claude config named volume is keyed by
|
settings.global_claude_instructions.as_deref(),
|
||||||
// project ID (not container ID) so it persists across recreation.
|
&settings.global_custom_env_vars,
|
||||||
let needs_recreation = docker::container_needs_recreation(&existing_id, &project)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if needs_recreation {
|
if needs_recreation {
|
||||||
log::info!("Container config changed, recreating container for project {}", project.id);
|
log::info!("Container config changed, recreating container for project {}", project.id);
|
||||||
let _ = docker::stop_container(&existing_id).await;
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
docker::remove_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(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
api_key.as_deref(),
|
||||||
@@ -121,29 +202,25 @@ pub async fn start_project_container(
|
|||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
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.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
|
||||||
|
|
||||||
@@ -166,7 +243,7 @@ pub async fn stop_project_container(
|
|||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
|
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
state.exec_manager.close_all_sessions().await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
|
|
||||||
docker::stop_container(container_id).await?;
|
docker::stop_container(container_id).await?;
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||||
@@ -187,7 +264,7 @@ pub async fn rebuild_project_container(
|
|||||||
|
|
||||||
// Remove existing container
|
// Remove existing container
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
state.exec_manager.close_all_sessions().await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
let _ = docker::stop_container(container_id).await;
|
let _ = docker::stop_container(container_id).await;
|
||||||
docker::remove_container(container_id).await?;
|
docker::remove_container(container_id).await?;
|
||||||
state.projects_store.set_container_id(&project_id, None)?;
|
state.projects_store.set_container_id(&project_id, None)?;
|
||||||
|
|||||||
117
app/src-tauri/src/commands/update_commands.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,96 @@ use bollard::container::{
|
|||||||
};
|
};
|
||||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
|
||||||
|
|
||||||
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
|
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||||
|
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||||
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for env_var in custom_env_vars {
|
||||||
|
let key = env_var.key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p));
|
||||||
|
if is_reserved {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(format!("{}={}", key, env_var.value));
|
||||||
|
}
|
||||||
|
parts.sort();
|
||||||
|
parts.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge global and per-project 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> {
|
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -45,12 +132,17 @@ pub async fn create_container(
|
|||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
|
|
||||||
let mut env_vars: Vec<String> = Vec::new();
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Tell CLI tools the terminal supports 24-bit RGB color
|
||||||
|
env_vars.push("COLORTERM=truecolor".to_string());
|
||||||
|
|
||||||
// Pass host UID/GID so the entrypoint can remap the container user
|
// Pass host UID/GID so the entrypoint can remap the container user
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -150,24 +242,54 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = vec![
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
// Project directory -> /workspace
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
Mount {
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
target: Some("/workspace".to_string()),
|
for env_var in &merged_env {
|
||||||
source: Some(project.path.clone()),
|
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),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
});
|
||||||
// Named volume for claude config persistence
|
}
|
||||||
Mount {
|
|
||||||
target: Some("/home/claude/.claude".to_string()),
|
// Named volume for claude config persistence
|
||||||
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
mounts.push(Mount {
|
||||||
typ: Some(MountTypeEnum::VOLUME),
|
target: Some("/home/claude/.claude".to_string()),
|
||||||
read_only: Some(false),
|
source: Some(format!("triple-c-claude-config-{}", project.id)),
|
||||||
..Default::default()
|
typ: Some(MountTypeEnum::VOLUME),
|
||||||
},
|
read_only: Some(false),
|
||||||
];
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
|
||||||
if let Some(ref ssh_path) = project.ssh_key_path {
|
if let Some(ref ssh_path) = project.ssh_key_path {
|
||||||
@@ -212,9 +334,17 @@ pub async fn create_container(
|
|||||||
|
|
||||||
// Docker socket (only if allowed)
|
// Docker socket (only if allowed)
|
||||||
if project.allow_docker_access {
|
if project.allow_docker_access {
|
||||||
|
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
|
||||||
|
// bind-mounted into a Linux container. Docker Desktop exposes the
|
||||||
|
// daemon socket as /var/run/docker.sock for container mounts.
|
||||||
|
let mount_source = if docker_socket_path == "//./pipe/docker_engine" {
|
||||||
|
"/var/run/docker.sock".to_string()
|
||||||
|
} else {
|
||||||
|
docker_socket_path.to_string()
|
||||||
|
};
|
||||||
mounts.push(Mount {
|
mounts.push(Mount {
|
||||||
target: Some("/var/run/docker.sock".to_string()),
|
target: Some("/var/run/docker.sock".to_string()),
|
||||||
source: Some(docker_socket_path.to_string()),
|
source: Some(mount_source),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(false),
|
read_only: Some(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -225,18 +355,28 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||||
|
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||||
|
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||||
|
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let working_dir = if project.paths.len() == 1 {
|
||||||
|
format!("/workspace/{}", project.paths[0].mount_name)
|
||||||
|
} else {
|
||||||
|
"/workspace".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
image: Some(image_name.to_string()),
|
image: Some(image_name.to_string()),
|
||||||
hostname: Some("triple-c".to_string()),
|
hostname: Some("triple-c".to_string()),
|
||||||
env: Some(env_vars),
|
env: Some(env_vars),
|
||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
working_dir: Some("/workspace".to_string()),
|
working_dir: Some(working_dir),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
tty: Some(true),
|
tty: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -276,10 +416,15 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
log::info!(
|
||||||
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
|
container_id
|
||||||
|
);
|
||||||
docker
|
docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
|
v: false, // preserve named volumes (claude config)
|
||||||
force: true,
|
force: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
@@ -291,30 +436,90 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
/// Check whether the existing container's configuration still matches the
|
/// Check whether the existing container's configuration still matches the
|
||||||
/// current project settings. Returns `true` when the container must be
|
/// current project settings. Returns `true` when the container must be
|
||||||
/// recreated (mounts or env vars differ).
|
/// recreated (mounts or env vars differ).
|
||||||
pub async fn container_needs_recreation(container_id: &str, project: &Project) -> Result<bool, String> {
|
pub async fn container_needs_recreation(
|
||||||
|
container_id: &str,
|
||||||
|
project: &Project,
|
||||||
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
.inspect_container(container_id, None)
|
.inspect_container(container_id, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to inspect container: {}", e))?;
|
.map_err(|e| format!("Failed to inspect container: {}", e))?;
|
||||||
|
|
||||||
|
let labels = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.labels.as_ref());
|
||||||
|
|
||||||
|
let get_label = |name: &str| -> Option<String> {
|
||||||
|
labels.and_then(|l| l.get(name).cloned())
|
||||||
|
};
|
||||||
|
|
||||||
let mounts = info
|
let mounts = info
|
||||||
.host_config
|
.host_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|hc| hc.mounts.as_ref());
|
.and_then(|hc| hc.mounts.as_ref());
|
||||||
|
|
||||||
// ── Docker socket mount ──────────────────────────────────────────────
|
// ── Docker socket mount ──────────────────────────────────────────────
|
||||||
let has_socket = mounts
|
// Intentionally NOT checked here. Toggling "Allow container spawning"
|
||||||
.map(|m| {
|
// should not trigger a full container recreation (which loses Claude
|
||||||
m.iter()
|
// Code settings stored in the named volume). The change takes effect
|
||||||
.any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock"))
|
// on the next explicit rebuild instead.
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
// ── Auth mode ────────────────────────────────────────────────────────
|
||||||
if has_socket != project.allow_docker_access {
|
let current_auth_mode = format!("{:?}", project.auth_mode);
|
||||||
log::info!("Docker socket mismatch (container={}, project={})", has_socket, project.allow_docker_access);
|
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
|
||||||
|
if container_auth_mode != current_auth_mode {
|
||||||
|
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project paths fingerprint ──────────────────────────────────────────
|
||||||
|
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
|
||||||
|
match get_label("triple-c.paths-fingerprint") {
|
||||||
|
Some(container_fp) => {
|
||||||
|
if container_fp != expected_paths_fp {
|
||||||
|
log::info!("Paths fingerprint mismatch (container={:?}, expected={:?})", container_fp, expected_paths_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Old container without paths-fingerprint label -> force recreation for migration
|
||||||
|
log::info!("Container missing paths-fingerprint label, triggering recreation for migration");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bedrock config fingerprint ───────────────────────────────────────
|
||||||
|
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
||||||
|
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
||||||
|
if container_bedrock_fp != expected_bedrock_fp {
|
||||||
|
log::info!("Bedrock config mismatch");
|
||||||
return Ok(true);
|
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 ───────────────────────────────────────────────
|
// ── SSH key path mount ───────────────────────────────────────────────
|
||||||
let ssh_mount_source = mounts
|
let ssh_mount_source = mounts
|
||||||
.and_then(|m| {
|
.and_then(|m| {
|
||||||
@@ -363,6 +568,26 @@ pub async fn container_needs_recreation(container_id: &str, project: &Project) -
|
|||||||
return Ok(true);
|
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)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use super::client::get_docker;
|
|||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
|
pub container_id: String,
|
||||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||||
shutdown_tx: mpsc::Sender<()>,
|
shutdown_tx: mpsc::Sender<()>,
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,7 @@ impl ExecSessionManager {
|
|||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
|
container_id: container_id.to_string(),
|
||||||
input_tx,
|
input_tx,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
};
|
};
|
||||||
@@ -161,11 +163,26 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||||
let sessions = self.sessions.lock().await;
|
// Clone the exec_id under the lock, then drop the lock before the
|
||||||
let session = sessions
|
// async Docker API call to avoid holding the mutex across await.
|
||||||
.get(session_id)
|
let exec_id = {
|
||||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
let sessions = self.sessions.lock().await;
|
||||||
session.resize(cols, rows).await
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
|
session.exec_id.clone()
|
||||||
|
};
|
||||||
|
let docker = get_docker()?;
|
||||||
|
docker
|
||||||
|
.resize_exec(
|
||||||
|
&exec_id,
|
||||||
|
ResizeExecOptions {
|
||||||
|
width: cols,
|
||||||
|
height: rows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn close_session(&self, session_id: &str) {
|
pub async fn close_session(&self, session_id: &str) {
|
||||||
@@ -175,6 +192,20 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let ids_to_close: Vec<String> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.container_id == container_id)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in ids_to_close {
|
||||||
|
if let Some(session) = sessions.remove(&id) {
|
||||||
|
session.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close_all_sessions(&self) {
|
pub async fn close_all_sessions(&self) {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
for (_, session) in sessions.drain() {
|
for (_, session) in sessions.drain() {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use docker::exec::ExecSessionManager;
|
use docker::exec::ExecSessionManager;
|
||||||
use storage::projects_store::ProjectsStore;
|
use storage::projects_store::ProjectsStore;
|
||||||
use storage::settings_store::SettingsStore;
|
use storage::settings_store::SettingsStore;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: ProjectsStore,
|
||||||
@@ -14,17 +16,53 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
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()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store: ProjectsStore::new(),
|
projects_store,
|
||||||
settings_store: SettingsStore::new(),
|
settings_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.ico")) {
|
||||||
|
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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Docker
|
// Docker
|
||||||
commands::docker_commands::check_docker,
|
commands::docker_commands::check_docker,
|
||||||
@@ -54,6 +92,9 @@ pub fn run() {
|
|||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
|
// Updates
|
||||||
|
commands::update_commands::get_app_version,
|
||||||
|
commands::update_commands::check_for_updates,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
73
app/src-tauri/src/logging.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImageSource {
|
pub enum ImageSource {
|
||||||
@@ -50,6 +60,14 @@ pub struct AppSettings {
|
|||||||
pub custom_image_name: Option<String>,
|
pub custom_image_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
|
#[serde(default = "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 {
|
impl Default for AppSettings {
|
||||||
@@ -62,6 +80,10 @@ impl Default for AppSettings {
|
|||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
|
global_claude_instructions: default_global_instructions(),
|
||||||
|
global_custom_env_vars: Vec::new(),
|
||||||
|
auto_check_updates: true,
|
||||||
|
dismissed_update_version: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod container_config;
|
pub mod container_config;
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
pub use container_config::*;
|
pub use container_config::*;
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EnvVar {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProjectPath {
|
||||||
|
pub host_path: String,
|
||||||
|
pub mount_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub paths: Vec<ProjectPath>,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
pub git_user_email: Option<String>,
|
pub git_user_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_env_vars: Vec<EnvVar>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_instructions: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -66,22 +83,26 @@ impl Default for BedrockAuthMethod {
|
|||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
pub auth_method: BedrockAuthMethod,
|
pub auth_method: BedrockAuthMethod,
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub aws_session_token: Option<String>,
|
pub aws_session_token: Option<String>,
|
||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub aws_bearer_token: Option<String>,
|
pub aws_bearer_token: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub disable_prompt_caching: bool,
|
pub disable_prompt_caching: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
pub fn new(name: String, path: String) -> Self {
|
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
Self {
|
Self {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name,
|
name,
|
||||||
path,
|
paths,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
@@ -91,6 +112,8 @@ impl Project {
|
|||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
|
custom_env_vars: Vec::new(),
|
||||||
|
claude_instructions: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -99,4 +122,29 @@ impl Project {
|
|||||||
pub fn container_name(&self) -> String {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrate a project JSON value from old single-`path` format to new `paths` format.
|
||||||
|
/// If the value already has `paths`, it is returned unchanged.
|
||||||
|
pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value {
|
||||||
|
if let Some(obj) = val.as_object_mut() {
|
||||||
|
if obj.contains_key("paths") {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
if let Some(path_val) = obj.remove("path") {
|
||||||
|
let path_str = path_val.as_str().unwrap_or("").to_string();
|
||||||
|
let mount_name = path_str
|
||||||
|
.trim_end_matches(['/', '\\'])
|
||||||
|
.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("workspace")
|
||||||
|
.to_string();
|
||||||
|
let project_path = serde_json::json!([{
|
||||||
|
"host_path": path_str,
|
||||||
|
"mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name },
|
||||||
|
}]);
|
||||||
|
obj.insert("paths".to_string(), project_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/src-tauri/src/models/update_info.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -10,42 +10,81 @@ pub struct ProjectsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectsStore {
|
impl ProjectsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Result<Self, String> {
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
||||||
.join("triple-c");
|
.join("triple-c");
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
fs::create_dir_all(&data_dir).ok();
|
||||||
|
|
||||||
let file_path = data_dir.join("projects.json");
|
let file_path = data_dir.join("projects.json");
|
||||||
|
|
||||||
let projects = if file_path.exists() {
|
let (projects, needs_save) = if file_path.exists() {
|
||||||
match fs::read_to_string(&file_path) {
|
match fs::read_to_string(&file_path) {
|
||||||
Ok(data) => match serde_json::from_str(&data) {
|
Ok(data) => {
|
||||||
Ok(parsed) => parsed,
|
// First try to parse as Vec<Value> to run migration
|
||||||
Err(e) => {
|
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
Ok(raw_values) => {
|
||||||
// Back up the corrupted file
|
let mut migrated = false;
|
||||||
let backup = file_path.with_extension("json.bak");
|
let migrated_values: Vec<serde_json::Value> = raw_values
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
.into_iter()
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
.map(|v| {
|
||||||
|
let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths"));
|
||||||
|
if has_path {
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
crate::models::Project::migrate_from_value(v)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Now deserialize the migrated values
|
||||||
|
let json_str = serde_json::to_string(&migrated_values).unwrap_or_default();
|
||||||
|
match serde_json::from_str::<Vec<crate::models::Project>>(&json_str) {
|
||||||
|
Ok(parsed) => (parsed, migrated),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||||
|
let backup = file_path.with_extension("json.bak");
|
||||||
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||||
|
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||||
|
}
|
||||||
|
(Vec::new(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||||
|
let backup = file_path.with_extension("json.bak");
|
||||||
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||||
|
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||||
|
}
|
||||||
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to read projects.json: {}", e);
|
log::error!("Failed to read projects.json: {}", e);
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
(Vec::new(), false)
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
let store = Self {
|
||||||
projects: Mutex::new(projects),
|
projects: Mutex::new(projects),
|
||||||
file_path,
|
file_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist migrated format back to disk
|
||||||
|
if needs_save {
|
||||||
|
log::info!("Migrated projects.json from single-path to multi-path format");
|
||||||
|
let projects = store.lock();
|
||||||
|
if let Err(e) = store.save(&projects) {
|
||||||
|
log::error!("Failed to save migrated projects: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||||
|
|||||||
@@ -36,3 +36,49 @@ pub fn has_api_key() -> Result<bool, String> {
|
|||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a per-project secret in the OS keychain.
|
||||||
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(value)
|
||||||
|
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a per-project secret from the OS keychain.
|
||||||
|
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
||||||
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all known secrets for a project from the OS keychain.
|
||||||
|
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
||||||
|
let secret_keys = [
|
||||||
|
"git-token",
|
||||||
|
"aws-access-key-id",
|
||||||
|
"aws-secret-access-key",
|
||||||
|
"aws-session-token",
|
||||||
|
"aws-bearer-token",
|
||||||
|
];
|
||||||
|
for key_name in &secret_keys {
|
||||||
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
let entry = keyring::Entry::new(&service, "secret")
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(keyring::Error::NoEntry) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ pub struct SettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsStore {
|
impl SettingsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Result<Self, String> {
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
||||||
.join("triple-c");
|
.join("triple-c");
|
||||||
|
|
||||||
fs::create_dir_all(&data_dir).ok();
|
fs::create_dir_all(&data_dir).ok();
|
||||||
@@ -41,10 +41,10 @@ impl SettingsStore {
|
|||||||
AppSettings::default()
|
AppSettings::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
settings: Mutex::new(settings),
|
settings: Mutex::new(settings),
|
||||||
file_path,
|
file_path,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.triple-c.app",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
@@ -6,21 +7,35 @@ import TerminalView from "./components/terminal/TerminalView";
|
|||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
import { useSettings } from "./hooks/useSettings";
|
import { useSettings } from "./hooks/useSettings";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage } = useDocker();
|
||||||
const { checkApiKey, loadSettings } = useSettings();
|
const { checkApiKey, loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { sessions, activeSessionId } = useAppState();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
|
const { sessions, activeSessionId } = useAppState(
|
||||||
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
checkDocker();
|
checkDocker().then((available) => {
|
||||||
checkImage();
|
if (available) checkImage();
|
||||||
|
});
|
||||||
checkApiKey();
|
checkApiKey();
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
|
// Update detection
|
||||||
|
loadVersion();
|
||||||
|
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
||||||
|
const cleanup = startPeriodicCheck();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(updateTimer);
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
54
app/src/components/layout/Sidebar.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { sidebarView, setSidebarView } = useAppState();
|
const { sidebarView, setSidebarView } = useAppState(
|
||||||
|
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 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">
|
||||||
@@ -32,7 +35,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
|
||||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
|
||||||
export default function StatusBar() {
|
export default function StatusBar() {
|
||||||
const { projects, sessions } = useAppState();
|
const { projects, sessions } = useAppState(
|
||||||
|
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
||||||
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import TerminalTabs from "../terminal/TerminalTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists } = useAppState();
|
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
updateInfo: s.updateInfo,
|
||||||
|
appVersion: s.appVersion,
|
||||||
|
setUpdateInfo: s.setUpdateInfo,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
if (appSettings && updateInfo) {
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
dismissed_update_version: updateInfo.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setUpdateInfo(null);
|
||||||
|
setShowUpdateDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<>
|
||||||
<div className="flex-1 overflow-x-auto pl-2">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
<TerminalTabs />
|
<div className="flex-1 overflow-x-auto pl-2">
|
||||||
|
<TerminalTabs />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
|
{updateInfo && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateDialog(true)}
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||||
|
<StatusDot ok={imageExists === true} label="Image" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
{showUpdateDialog && updateInfo && (
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<UpdateDialog
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
updateInfo={updateInfo}
|
||||||
</div>
|
currentVersion={appVersion}
|
||||||
</div>
|
onDismiss={handleDismiss}
|
||||||
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +1,211 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import type { ProjectPath } from "../../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PathEntry {
|
||||||
|
host_path: string;
|
||||||
|
mount_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basenameFromPath(p: string): string {
|
||||||
|
return p.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function AddProjectDialog({ onClose }: Props) {
|
export default function AddProjectDialog({ onClose }: Props) {
|
||||||
const { add } = useProjects();
|
const { add } = useProjects();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [path, setPath] = useState("");
|
const [pathEntries, setPathEntries] = useState<PathEntry[]>([
|
||||||
|
{ host_path: "", mount_name: "" },
|
||||||
|
]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
useEffect(() => {
|
||||||
|
nameInputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBrowse = async (index: number) => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (typeof selected === "string") {
|
if (typeof selected === "string") {
|
||||||
setPath(selected);
|
const basename = basenameFromPath(selected);
|
||||||
if (!name) {
|
const entries = [...pathEntries];
|
||||||
const parts = selected.replace(/[/\\]$/, "").split(/[/\\]/);
|
entries[index] = {
|
||||||
setName(parts[parts.length - 1]);
|
host_path: selected,
|
||||||
|
mount_name: entries[index].mount_name || basename,
|
||||||
|
};
|
||||||
|
setPathEntries(entries);
|
||||||
|
// Auto-fill project name from first folder
|
||||||
|
if (!name && index === 0) {
|
||||||
|
setName(basename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const updateEntry = (
|
||||||
if (!name.trim() || !path.trim()) {
|
index: number,
|
||||||
setError("Name and path are required");
|
field: keyof PathEntry,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const entries = [...pathEntries];
|
||||||
|
entries[index] = { ...entries[index], [field]: value };
|
||||||
|
setPathEntries(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (index: number) => {
|
||||||
|
setPathEntries(pathEntries.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Project name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validPaths: ProjectPath[] = pathEntries
|
||||||
|
.filter((p) => p.host_path.trim())
|
||||||
|
.map((p) => ({
|
||||||
|
host_path: p.host_path.trim(),
|
||||||
|
mount_name: p.mount_name.trim() || basenameFromPath(p.host_path),
|
||||||
|
}));
|
||||||
|
if (validPaths.length === 0) {
|
||||||
|
setError("At least one folder path is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mountNames = validPaths.map((p) => p.mount_name);
|
||||||
|
if (new Set(mountNames).size !== mountNames.length) {
|
||||||
|
setError("Mount names must be unique");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await add(name.trim(), path.trim());
|
await add(name.trim(), validPaths);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
setError(String(e));
|
setError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<form onSubmit={handleSubmit}>
|
||||||
Project Name
|
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
</label>
|
Project Name
|
||||||
<input
|
</label>
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="my-project"
|
|
||||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
|
||||||
Project Path
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<input
|
<input
|
||||||
value={path}
|
ref={nameInputRef}
|
||||||
onChange={(e) => setPath(e.target.value)}
|
value={name}
|
||||||
placeholder="/path/to/project"
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
placeholder="my-project"
|
||||||
|
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleBrowse}
|
|
||||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
Folders
|
||||||
)}
|
</label>
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{pathEntries.map((entry, i) => (
|
||||||
|
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
value={entry.host_path}
|
||||||
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
|
placeholder="/path/to/folder"
|
||||||
|
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBrowse(i)}
|
||||||
|
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
{pathEntries.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(i)}
|
||||||
|
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
||||||
|
<input
|
||||||
|
value={entry.mount_name}
|
||||||
|
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||||
|
placeholder="mount-name"
|
||||||
|
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEntry}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
{error && (
|
||||||
<button
|
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||||
onClick={onClose}
|
)}
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
<div className="flex justify-end gap-2">
|
||||||
Cancel
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={onClose}
|
||||||
onClick={handleSubmit}
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
disabled={loading}
|
>
|
||||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
Cancel
|
||||||
>
|
</button>
|
||||||
{loading ? "Adding..." : "Add Project"}
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
</div>
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Adding..." : "Add Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
80
app/src/components/projects/ClaudeInstructionsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/src/components/projects/EnvVarsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
app/src/components/projects/ProjectCard.test.tsx
Normal 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: "login",
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,65 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectCard({ project }: Props) {
|
export default function ProjectCard({ project }: Props) {
|
||||||
const { selectedProjectId, setSelectedProject } = useAppState();
|
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
||||||
|
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
||||||
const { start, stop, rebuild, remove, update } = useProjects();
|
const { start, stop, rebuild, remove, update } = useProjects();
|
||||||
const { open: openTerminal } = useTerminal();
|
const { open: openTerminal } = useTerminal();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
|
// Local state for text fields (save on blur, not on every keystroke)
|
||||||
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||||
|
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
||||||
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
|
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||||
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
|
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
||||||
|
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
||||||
|
|
||||||
|
// Bedrock local state for text fields
|
||||||
|
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
|
const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
|
const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
|
const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? "");
|
||||||
|
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
|
||||||
|
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
|
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
||||||
|
|
||||||
|
// Sync local state when project prop changes (e.g., after save or external update)
|
||||||
|
useEffect(() => {
|
||||||
|
setPaths(project.paths ?? []);
|
||||||
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
|
setGitName(project.git_user_name ?? "");
|
||||||
|
setGitEmail(project.git_user_email ?? "");
|
||||||
|
setGitToken(project.git_token ?? "");
|
||||||
|
setClaudeInstructions(project.claude_instructions ?? "");
|
||||||
|
setEnvVars(project.custom_env_vars ?? []);
|
||||||
|
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
|
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
|
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
|
setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? "");
|
||||||
|
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
||||||
|
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
||||||
|
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -79,7 +120,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
try {
|
try {
|
||||||
const current = project.bedrock_config ?? defaultBedrockConfig;
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.error("Failed to update Bedrock config:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowseSSH = async () => {
|
const handleBrowseSSH = async () => {
|
||||||
@@ -93,6 +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 = {
|
const statusColor = {
|
||||||
stopped: "bg-[var(--text-secondary)]",
|
stopped: "bg-[var(--text-secondary)]",
|
||||||
starting: "bg-[var(--warning)]",
|
starting: "bg-[var(--warning)]",
|
||||||
@@ -104,7 +243,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedProject(project.id)}
|
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
|
isSelected
|
||||||
? "bg-[var(--bg-tertiary)]"
|
? "bg-[var(--bg-tertiary)]"
|
||||||
: "hover:bg-[var(--bg-tertiary)]"
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
@@ -114,12 +253,16 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] truncate mt-0.5 ml-4">
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||||
{project.path}
|
{project.paths.map((pp, i) => (
|
||||||
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<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 */}
|
{/* Auth mode selector */}
|
||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
@@ -202,16 +345,109 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] 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 */}
|
{/* SSH Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={project.ssh_key_path ?? ""}
|
value={sshKeyPath}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||||
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
|
onBlur={handleSshKeyPathBlur}
|
||||||
}}
|
|
||||||
placeholder="~/.ssh"
|
placeholder="~/.ssh"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
@@ -230,10 +466,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||||
<input
|
<input
|
||||||
value={project.git_user_name ?? ""}
|
value={gitName}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitName(e.target.value)}
|
||||||
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
|
onBlur={handleGitNameBlur}
|
||||||
}}
|
|
||||||
placeholder="Your Name"
|
placeholder="Your Name"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
@@ -244,10 +479,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||||
<input
|
<input
|
||||||
value={project.git_user_email ?? ""}
|
value={gitEmail}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitEmail(e.target.value)}
|
||||||
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
|
onBlur={handleGitEmailBlur}
|
||||||
}}
|
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
@@ -259,10 +493,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={project.git_token ?? ""}
|
value={gitToken}
|
||||||
onChange={async (e) => {
|
onChange={(e) => setGitToken(e.target.value)}
|
||||||
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
|
onBlur={handleGitTokenBlur}
|
||||||
}}
|
|
||||||
placeholder="ghp_..."
|
placeholder="ghp_..."
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
@@ -274,7 +507,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch {}
|
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
||||||
|
console.error("Failed to update Docker access setting:", err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||||
@@ -287,6 +522,32 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Bedrock config */}
|
||||||
{project.auth_mode === "bedrock" && (() => {
|
{project.auth_mode === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
@@ -318,8 +579,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_region}
|
value={bedrockRegion}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })}
|
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||||
|
onBlur={handleBedrockRegionBlur}
|
||||||
placeholder="us-east-1"
|
placeholder="us-east-1"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -332,8 +594,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_access_key_id ?? ""}
|
value={bedrockAccessKeyId}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })}
|
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||||
|
onBlur={handleBedrockAccessKeyIdBlur}
|
||||||
placeholder="AKIA..."
|
placeholder="AKIA..."
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -343,8 +606,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_secret_access_key ?? ""}
|
value={bedrockSecretKey}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })}
|
onChange={(e) => setBedrockSecretKey(e.target.value)}
|
||||||
|
onBlur={handleBedrockSecretKeyBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -353,8 +617,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_session_token ?? ""}
|
value={bedrockSessionToken}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })}
|
onChange={(e) => setBedrockSessionToken(e.target.value)}
|
||||||
|
onBlur={handleBedrockSessionTokenBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -367,8 +632,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||||
<input
|
<input
|
||||||
value={bc.aws_profile ?? ""}
|
value={bedrockProfile}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })}
|
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||||
|
onBlur={handleBedrockProfileBlur}
|
||||||
placeholder="default"
|
placeholder="default"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -382,8 +648,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={bc.aws_bearer_token ?? ""}
|
value={bedrockBearerToken}
|
||||||
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })}
|
onChange={(e) => setBedrockBearerToken(e.target.value)}
|
||||||
|
onBlur={handleBedrockBearerTokenBlur}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
@@ -394,8 +661,9 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||||
<input
|
<input
|
||||||
value={bc.model_id ?? ""}
|
value={bedrockModelId}
|
||||||
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })}
|
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||||
|
onBlur={handleBedrockModelIdBlur}
|
||||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -412,6 +680,30 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export default function DockerSettings() {
|
|||||||
const handleSourceChange = async (source: ImageSource) => {
|
const handleSourceChange = async (source: ImageSource) => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({ ...appSettings, image_source: source });
|
await saveSettings({ ...appSettings, image_source: source });
|
||||||
// Re-check image existence after changing source
|
await checkImage();
|
||||||
setTimeout(() => checkImage(), 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomChange = async (value: string) => {
|
const handleCustomChange = async (value: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const { appVersion, checkForUpdates } = useUpdates();
|
||||||
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
|
const [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 (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
@@ -11,6 +45,104 @@ export default function SettingsPanel() {
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
app/src/components/settings/UpdateDialog.tsx
Normal 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)]">→</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,6 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
|
||||||
/** Strip ANSI escape sequences from a string. */
|
|
||||||
function stripAnsi(s: string): string {
|
|
||||||
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -21,6 +16,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,13 +64,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
// Try WebGL renderer, fall back silently
|
// WebGL addon is loaded/disposed dynamically in the active effect
|
||||||
try {
|
// to avoid exhausting the browser's limited WebGL context pool.
|
||||||
const webglAddon = new WebglAddon();
|
|
||||||
term.loadAddon(webglAddon);
|
|
||||||
} catch {
|
|
||||||
// WebGL not available, canvas renderer is fine
|
|
||||||
}
|
|
||||||
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
@@ -88,81 +79,84 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── URL accumulator ──────────────────────────────────────────────
|
|
||||||
// Claude Code login emits a long OAuth URL that gets split across
|
|
||||||
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
|
||||||
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
|
||||||
// truncated and the link fails when clicked.
|
|
||||||
//
|
|
||||||
// Fix: buffer recent output, strip ANSI codes, and after a short
|
|
||||||
// debounce check for a URL that spans multiple lines. When found,
|
|
||||||
// write a single clean clickable copy to the terminal.
|
|
||||||
let outputBuffer = "";
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const flushUrlBuffer = () => {
|
|
||||||
const plain = stripAnsi(outputBuffer);
|
|
||||||
// Reassemble: strip hard newlines and carriage returns to join
|
|
||||||
// fragments that were split across terminal lines.
|
|
||||||
const joined = plain.replace(/[\r\n]+/g, "");
|
|
||||||
// Look for a long OAuth/auth URL (Claude login URLs contain
|
|
||||||
// "oauth" or "console.anthropic.com" or "/authorize").
|
|
||||||
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
|
||||||
if (match) {
|
|
||||||
const url = match[0];
|
|
||||||
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
|
||||||
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
|
||||||
}
|
|
||||||
outputBuffer = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let unlistenOutput: (() => void) | null = null;
|
let aborted = false;
|
||||||
let unlistenExit: (() => void) | null = null;
|
|
||||||
|
|
||||||
onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
|
if (aborted) return;
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
|
||||||
// Accumulate for URL detection
|
|
||||||
outputBuffer += data;
|
|
||||||
// Cap buffer size to avoid memory growth
|
|
||||||
if (outputBuffer.length > 8192) {
|
|
||||||
outputBuffer = outputBuffer.slice(-4096);
|
|
||||||
}
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
unlistenOutput = unlisten;
|
if (aborted) unlisten();
|
||||||
|
return unlisten;
|
||||||
});
|
});
|
||||||
|
|
||||||
onExit(sessionId, () => {
|
const exitPromise = onExit(sessionId, () => {
|
||||||
|
if (aborted) return;
|
||||||
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
term.write("\r\n\x1b[33m[Session ended]\x1b[0m\r\n");
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
unlistenExit = unlisten;
|
if (aborted) unlisten();
|
||||||
|
return unlisten;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize (throttled via requestAnimationFrame to avoid excessive calls).
|
||||||
|
// Skip resize work for hidden terminals — containerRef will have 0 dimensions.
|
||||||
|
let resizeRafId: number | null = null;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit();
|
if (resizeRafId !== null) return;
|
||||||
resize(sessionId, term.cols, term.rows);
|
const el = containerRef.current;
|
||||||
|
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||||
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
|
resizeRafId = null;
|
||||||
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
|
fitAddon.fit();
|
||||||
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
aborted = true;
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
unlistenOutput?.();
|
outputPromise.then((fn) => fn?.());
|
||||||
unlistenExit?.();
|
exitPromise.then((fn) => fn?.());
|
||||||
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Re-fit when tab becomes active
|
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||||
|
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||||
|
// the browser's limited pool (~8-16 contexts).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && fitRef.current && termRef.current) {
|
const term = termRef.current;
|
||||||
fitRef.current.fit();
|
if (!term) return;
|
||||||
termRef.current.focus();
|
|
||||||
|
if (active) {
|
||||||
|
// Attach WebGL renderer
|
||||||
|
if (!webglRef.current) {
|
||||||
|
try {
|
||||||
|
const addon = new WebglAddon();
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
try { addon.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
});
|
||||||
|
term.loadAddon(addon);
|
||||||
|
webglRef.current = addon;
|
||||||
|
} catch {
|
||||||
|
// WebGL not available, canvas renderer is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.focus();
|
||||||
|
} else {
|
||||||
|
// Release WebGL context for inactive terminals
|
||||||
|
if (webglRef.current) {
|
||||||
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
@@ -9,7 +10,14 @@ export function useDocker() {
|
|||||||
setDockerAvailable,
|
setDockerAvailable,
|
||||||
imageExists,
|
imageExists,
|
||||||
setImageExists,
|
setImageExists,
|
||||||
} = useAppState();
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
setDockerAvailable: s.setDockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
setImageExists: s.setImageExists,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const checkDocker = useCallback(async () => {
|
const checkDocker = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { ProjectPath } from "../lib/types";
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const {
|
const {
|
||||||
@@ -10,7 +12,16 @@ export function useProjects() {
|
|||||||
setSelectedProject,
|
setSelectedProject,
|
||||||
updateProjectInList,
|
updateProjectInList,
|
||||||
removeProjectFromList,
|
removeProjectFromList,
|
||||||
} = useAppState();
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
projects: s.projects,
|
||||||
|
selectedProjectId: s.selectedProjectId,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setSelectedProject: s.setSelectedProject,
|
||||||
|
updateProjectInList: s.updateProjectInList,
|
||||||
|
removeProjectFromList: s.removeProjectFromList,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
|
||||||
|
|
||||||
@@ -20,8 +31,8 @@ export function useProjects() {
|
|||||||
}, [setProjects]);
|
}, [setProjects]);
|
||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
async (name: string, path: string) => {
|
async (name: string, paths: ProjectPath[]) => {
|
||||||
const project = await commands.addProject(name, path);
|
const project = await commands.addProject(name, paths);
|
||||||
// Refresh from backend to avoid stale closure issues
|
// Refresh from backend to avoid stale closure issues
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState();
|
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
hasKey: s.hasKey,
|
||||||
|
setHasKey: s.setHasKey,
|
||||||
|
appSettings: s.appSettings,
|
||||||
|
setAppSettings: s.setAppSettings,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const checkApiKey = useCallback(async () => {
|
const checkApiKey = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||||
useAppState();
|
useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
sessions: s.sessions,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
addSession: s.addSession,
|
||||||
|
removeSession: s.removeSession,
|
||||||
|
setActiveSession: s.setActiveSession,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
async (projectId: string, projectName: string) => {
|
async (projectId: string, projectName: string) => {
|
||||||
|
|||||||
72
app/src/hooks/useUpdates.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -12,8 +12,8 @@ export const listSiblingContainers = () =>
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||||
export const addProject = (name: string, path: string) =>
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||||
invoke<Project>("add_project", { name, path });
|
invoke<Project>("add_project", { name, paths });
|
||||||
export const removeProject = (projectId: string) =>
|
export const removeProject = (projectId: string) =>
|
||||||
invoke<void>("remove_project", { projectId });
|
invoke<void>("remove_project", { projectId });
|
||||||
export const updateProject = (project: Project) =>
|
export const updateProject = (project: Project) =>
|
||||||
@@ -49,3 +49,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|||||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||||
export const closeTerminalSession = (sessionId: string) =>
|
export const closeTerminalSession = (sessionId: string) =>
|
||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
|
export const checkForUpdates = () =>
|
||||||
|
invoke<UpdateInfo | null>("check_for_updates");
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
export interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPath {
|
||||||
|
host_path: string;
|
||||||
|
mount_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
paths: ProjectPath[];
|
||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
auth_mode: AuthMode;
|
auth_mode: AuthMode;
|
||||||
@@ -11,6 +21,8 @@ export interface Project {
|
|||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
|
custom_env_vars: EnvVar[];
|
||||||
|
claude_instructions: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -75,4 +87,23 @@ export interface AppSettings {
|
|||||||
image_source: ImageSource;
|
image_source: ImageSource;
|
||||||
custom_image_name: string | null;
|
custom_image_name: string | null;
|
||||||
global_aws: GlobalAwsSettings;
|
global_aws: GlobalAwsSettings;
|
||||||
|
global_claude_instructions: string | null;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -30,6 +30,12 @@ interface AppState {
|
|||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||||
|
appVersion: string;
|
||||||
|
setAppVersion: (version: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
@@ -85,4 +91,10 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
updateInfo: null,
|
||||||
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||||
|
appVersion: "",
|
||||||
|
setAppVersion: (version) => set({ appVersion: version }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
36
app/src/test/icon-config.test.ts
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -17,5 +17,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/vitest.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -50,9 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-venv \
|
python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
|
||||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
|
||||||
|
|
||||||
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
|
# ── Docker CLI (not daemon) ─────────────────────────────────────────────────
|
||||||
RUN install -m 0755 -d /etc/apt/keyrings \
|
RUN install -m 0755 -d /etc/apt/keyrings \
|
||||||
@@ -65,8 +63,11 @@ RUN install -m 0755 -d /etc/apt/keyrings \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
||||||
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
|
RUN ARCH=$(uname -m) && \
|
||||||
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
|
curl "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o "awscliv2.zip" && \
|
||||||
|
unzip -q awscliv2.zip && \
|
||||||
|
./aws/install && \
|
||||||
|
rm -rf awscliv2.zip aws
|
||||||
|
|
||||||
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
||||||
RUN useradd -m -s /bin/bash -u 1000 claude \
|
RUN useradd -m -s /bin/bash -u 1000 claude \
|
||||||
@@ -83,7 +84,7 @@ WORKDIR /home/claude
|
|||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
ENV PATH="/home/claude/.cargo/bin:${PATH}"
|
ENV PATH="/home/claude/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
# Add uv/ruff to PATH (installed to /root by default, reinstall for claude user)
|
# Install uv and ruff for claude user
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
&& curl -LsSf https://astral.sh/ruff/install.sh | sh
|
||||||
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"
|
ENV PATH="/home/claude/.local/bin:/home/claude/.cargo/bin:${PATH}"
|
||||||
|
|||||||
@@ -82,16 +82,25 @@ if [ -n "$GIT_TOKEN" ]; then
|
|||||||
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE"
|
||||||
echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE"
|
||||||
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
|
echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE"
|
||||||
su -s /bin/bash claude -c "git config --global credential.helper 'store --file=$CRED_FILE'"
|
git config --global --file /home/claude/.gitconfig credential.helper "store --file=$CRED_FILE"
|
||||||
unset GIT_TOKEN
|
unset GIT_TOKEN
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Git user config ──────────────────────────────────────────────────────────
|
# ── Git user config ──────────────────────────────────────────────────────────
|
||||||
if [ -n "$GIT_USER_NAME" ]; then
|
if [ -n "$GIT_USER_NAME" ]; then
|
||||||
su -s /bin/bash claude -c "git config --global user.name '$GIT_USER_NAME'"
|
git config --global --file /home/claude/.gitconfig user.name "$GIT_USER_NAME"
|
||||||
fi
|
fi
|
||||||
if [ -n "$GIT_USER_EMAIL" ]; then
|
if [ -n "$GIT_USER_EMAIL" ]; then
|
||||||
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'"
|
git config --global --file /home/claude/.gitconfig user.email "$GIT_USER_EMAIL"
|
||||||
|
fi
|
||||||
|
chown claude:claude /home/claude/.gitconfig 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Claude instructions ──────────────────────────────────────────────────────
|
||||||
|
if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||||
|
mkdir -p /home/claude/.claude
|
||||||
|
printf '%s\n' "$CLAUDE_INSTRUCTIONS" > /home/claude/.claude/CLAUDE.md
|
||||||
|
chown claude:claude /home/claude/.claude/CLAUDE.md
|
||||||
|
unset CLAUDE_INSTRUCTIONS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
|
|||||||