Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+41
-4
@@ -101,9 +101,16 @@ Two viable options:
|
|||||||
### Option A — long-lived token injection (simple)
|
### Option A — long-lived token injection (simple)
|
||||||
|
|
||||||
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
|
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
|
||||||
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it
|
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
|
||||||
once on the host, stores the token in the OS keychain via the existing `secure.rs`, and
|
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
|
||||||
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic backend.
|
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
|
||||||
|
|
||||||
|
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
|
||||||
|
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
|
||||||
|
under a pty: its `redirect_uri` is Anthropic-hosted
|
||||||
|
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
|
||||||
|
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
|
||||||
|
is therefore mandatory — the flow cannot complete without one.
|
||||||
|
|
||||||
- No routing, no ports, no proxy.
|
- No routing, no ports, no proxy.
|
||||||
- One auth event covers every project.
|
- One auth event covers every project.
|
||||||
@@ -111,6 +118,12 @@ injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic b
|
|||||||
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
|
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
|
||||||
token cannot drive Remote Control sessions or claude.ai connector fetches.
|
token cannot drive Remote Control sessions or claude.ai connector fetches.
|
||||||
|
|
||||||
|
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
|
||||||
|
label, not a hash of the token. Labels are readable by anything that can run
|
||||||
|
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
|
||||||
|
token you could confirm it. A presence boolean would instead miss rotations and silently
|
||||||
|
leave containers on a stale token.
|
||||||
|
|
||||||
### Option B — the Auth Bridge (general loopback-callback bridge)
|
### Option B — the Auth Bridge (general loopback-callback bridge)
|
||||||
|
|
||||||
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
|
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
|
||||||
@@ -187,4 +200,28 @@ the config volume by the entrypoint. Generalizes the pattern the MCP tab was rea
|
|||||||
3. **Stale model placeholders** — see "Not yet scheduled" above.
|
3. **Stale model placeholders** — see "Not yet scheduled" above.
|
||||||
|
|
||||||
4. **Silent save failures.** Project config saves on blur; failures go only to
|
4. **Silent save failures.** Project config saves on blur; failures go only to
|
||||||
`console.error`. No user-visible indication. Addressed in Phase 3.
|
`console.error`. No user-visible indication. Fixed in Phase 3 — `useProjectSave`
|
||||||
|
now renders a Saved / Saving / Save failed indicator and raises a toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps left by Phase 2–3
|
||||||
|
|
||||||
|
- **`open_terminal_session` takes no command argument.** "Resume session" and
|
||||||
|
"Manage in terminal" therefore open a bash tab and *type* the command after a
|
||||||
|
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
|
||||||
|
slow container start. The fix is a `command: Option<String>` parameter on the
|
||||||
|
Tauri command so the exec launches the process directly.
|
||||||
|
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
|
||||||
|
with no start time, so Project Home records "running since" when the app *sees*
|
||||||
|
the transition. A container already running when the app launches shows
|
||||||
|
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
|
||||||
|
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
|
||||||
|
access in the build environment used for this cycle. The existing inline SVGs and
|
||||||
|
text glyphs remain.
|
||||||
|
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
|
||||||
|
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
|
||||||
|
the tabs in the TopBar; the diagram won. Worth revisiting.
|
||||||
|
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
|
||||||
|
`kill-word`, used constantly inside the terminal this app is built around;
|
||||||
|
intercepting it globally would break word-erase in every shell.
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
|
||||||
|
//! container complete against the browser on the *host*.
|
||||||
|
//!
|
||||||
|
//! ## The problem
|
||||||
|
//!
|
||||||
|
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
|
||||||
|
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
|
||||||
|
//! then open a browser at a provider URL whose redirect points back to
|
||||||
|
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
|
||||||
|
//! is on the *container's* loopback, the browser is on the *host's*, and the
|
||||||
|
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
|
||||||
|
//! configurable, so nothing can be pre-published at container creation time.
|
||||||
|
//!
|
||||||
|
//! ## The mechanism
|
||||||
|
//!
|
||||||
|
//! While the bridge is enabled for a running project, poll the container every
|
||||||
|
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
|
||||||
|
//! that appears, bind the *same* port on the host's loopback and proxy each
|
||||||
|
//! accepted connection into the container over `docker exec … socat` (see
|
||||||
|
//! [`tunnel`]). When the in-container listener goes away, drop the host
|
||||||
|
//! listener. The host and container therefore agree on the port number, which is
|
||||||
|
//! the whole trick: the redirect URL the provider was given resolves correctly
|
||||||
|
//! on both sides.
|
||||||
|
//!
|
||||||
|
//! ## Lifecycle and teardown
|
||||||
|
//!
|
||||||
|
//! One poller task per project. It is the only thing that owns
|
||||||
|
//! [`PortForward`]s, and it always tears them down on its way out, so every way
|
||||||
|
//! the bridge can end funnels through the same code:
|
||||||
|
//!
|
||||||
|
//! | Trigger | Path |
|
||||||
|
//! |---|---|
|
||||||
|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||||
|
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||||
|
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||||
|
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||||
|
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||||
|
//!
|
||||||
|
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
|
||||||
|
//! released before it returns. As a backstop for any path that skips all of the
|
||||||
|
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
|
||||||
|
//! the accept loop, which drops the socket.
|
||||||
|
|
||||||
|
pub mod proc_net;
|
||||||
|
pub mod tunnel;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
use crate::storage::projects_store::ProjectsStore;
|
||||||
|
|
||||||
|
use proc_net::PortFamily;
|
||||||
|
use tunnel::PortForward;
|
||||||
|
|
||||||
|
/// How often the container is polled for new/vanished loopback listeners.
|
||||||
|
/// Short enough that a login redirect isn't left waiting, cheap enough to run
|
||||||
|
/// continuously (one `cat` of two procfs files per tick).
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Emitted whenever the bridged-port set (or the conflict set) changes.
|
||||||
|
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
|
||||||
|
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IPC response models
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A port currently bound on the host loopback and forwarded into the container.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BridgedPort {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
|
pub bridged_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PortConflict {
|
||||||
|
pub port: u16,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AuthBridgeStatus {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub active_ports: Vec<BridgedPort>,
|
||||||
|
pub conflicts: Vec<PortConflict>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeStatus {
|
||||||
|
fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
active_ports: Vec::new(),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manager
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Everything the poller owns for one project. Live ports and conflicts sit
|
||||||
|
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||||
|
/// disturbing the poller.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BridgeState {
|
||||||
|
forwards: BTreeMap<u16, PortForward>,
|
||||||
|
conflicts: BTreeMap<u16, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeState {
|
||||||
|
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
active_ports: self
|
||||||
|
.forwards
|
||||||
|
.values()
|
||||||
|
.map(|f| BridgedPort {
|
||||||
|
port: f.port,
|
||||||
|
family: f.family,
|
||||||
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
conflicts: self
|
||||||
|
.conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|(port, reason)| PortConflict {
|
||||||
|
port: *port,
|
||||||
|
reason: reason.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectBridge {
|
||||||
|
/// Distinguishes this poller from a later one for the same project, so a
|
||||||
|
/// poller that exits late can't remove its replacement's map entry.
|
||||||
|
epoch: u64,
|
||||||
|
cancel: watch::Sender<bool>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
poller: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AuthBridgeManager {
|
||||||
|
bridges: BridgeMap,
|
||||||
|
next_epoch: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthBridgeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start polling for `project_id`. Idempotent: a call while a live poller
|
||||||
|
/// already exists for the project is a no-op.
|
||||||
|
pub async fn start(
|
||||||
|
&self,
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
) {
|
||||||
|
let mut map = self.bridges.lock().await;
|
||||||
|
|
||||||
|
// A finished poller has already torn its ports down, so its entry is
|
||||||
|
// just a husk and can be replaced. A live one means we're already on.
|
||||||
|
if map
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|b| !b.poller.is_finished())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||||
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: starting for project {} (container {})",
|
||||||
|
project_id,
|
||||||
|
&container_id[..container_id.len().min(12)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let poller = tokio::spawn(poll_loop(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
epoch,
|
||||||
|
app,
|
||||||
|
store,
|
||||||
|
state.clone(),
|
||||||
|
self.bridges.clone(),
|
||||||
|
cancel_rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
project_id,
|
||||||
|
ProjectBridge {
|
||||||
|
epoch,
|
||||||
|
cancel: cancel_tx,
|
||||||
|
state,
|
||||||
|
poller,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the bridge for one project and wait until every host port it held
|
||||||
|
/// has been released.
|
||||||
|
pub async fn stop(&self, project_id: &str) {
|
||||||
|
// Remove under the lock, then release it before awaiting: the poller
|
||||||
|
// takes the same lock to deregister itself on exit.
|
||||||
|
let bridge = self.bridges.lock().await.remove(project_id);
|
||||||
|
if let Some(bridge) = bridge {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every bridge. Used on app exit.
|
||||||
|
pub async fn stop_all(&self) {
|
||||||
|
let bridges: Vec<(String, ProjectBridge)> =
|
||||||
|
self.bridges.lock().await.drain().collect();
|
||||||
|
for (project_id, bridge) in bridges {
|
||||||
|
let _ = bridge.cancel.send(true);
|
||||||
|
let _ = bridge.poller.await;
|
||||||
|
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current status. `enabled` comes from the persisted project record, so a
|
||||||
|
/// project whose bridge is on but whose container is stopped still reports
|
||||||
|
/// `enabled: true` with no active ports.
|
||||||
|
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||||
|
let map = self.bridges.lock().await;
|
||||||
|
match map.get(project_id) {
|
||||||
|
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
|
||||||
|
None => AuthBridgeStatus {
|
||||||
|
enabled,
|
||||||
|
..AuthBridgeStatus::disabled()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Poller
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn poll_loop(
|
||||||
|
project_id: String,
|
||||||
|
container_id: String,
|
||||||
|
epoch: u64,
|
||||||
|
app: AppHandle,
|
||||||
|
store: Arc<ProjectsStore>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
bridges: BridgeMap,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
let mut exec_failures: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Stop conditions checked every tick, so the bridge winds itself down
|
||||||
|
// even when nothing calls `stop()` (container died, project deleted
|
||||||
|
// out from under us, flag flipped off by another path).
|
||||||
|
let project = match store.get(&project_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !project.auth_bridge_enabled {
|
||||||
|
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One exec per tick reads both procfs files.
|
||||||
|
let cmd = vec![
|
||||||
|
"cat".to_string(),
|
||||||
|
"/proc/net/tcp".to_string(),
|
||||||
|
"/proc/net/tcp6".to_string(),
|
||||||
|
];
|
||||||
|
// Cancellation races the exec, not just the sleep, so disabling the
|
||||||
|
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||||
|
let discovery = tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
res = exec_oneshot(&container_id, cmd) => res,
|
||||||
|
};
|
||||||
|
|
||||||
|
match discovery {
|
||||||
|
Ok(text) => {
|
||||||
|
exec_failures = 0;
|
||||||
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||||
|
let skip = skipped_ports(&project);
|
||||||
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
|
emit_status(&app, &project_id, &state, true).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
exec_failures += 1;
|
||||||
|
// Transient failures happen (container restarting, engine busy);
|
||||||
|
// only complain once per streak.
|
||||||
|
if exec_failures == 1 {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.changed() => break,
|
||||||
|
_ = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown(&project_id, &state).await;
|
||||||
|
emit_status(
|
||||||
|
&app,
|
||||||
|
&project_id,
|
||||||
|
&state,
|
||||||
|
store
|
||||||
|
.get(&project_id)
|
||||||
|
.is_some_and(|p| p.auth_bridge_enabled),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Deregister, unless a newer poller has already taken this project's slot.
|
||||||
|
let mut map = bridges.lock().await;
|
||||||
|
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
|
||||||
|
map.remove(&project_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports Docker already handles for this project. A container port that is
|
||||||
|
/// explicitly published has a host-side path already, and the mapping's host
|
||||||
|
/// port is a binding we must not fight over.
|
||||||
|
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||||
|
project
|
||||||
|
.port_mappings
|
||||||
|
.iter()
|
||||||
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the set of host listeners in line with what the container is currently
|
||||||
|
/// listening on. Returns whether anything the UI cares about changed.
|
||||||
|
async fn reconcile(
|
||||||
|
container_id: &str,
|
||||||
|
discovered: &BTreeMap<u16, PortFamily>,
|
||||||
|
skip: &HashSet<u16>,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
|
||||||
|
// Drop host listeners whose container-side counterpart vanished, became
|
||||||
|
// covered by an explicit port mapping, or changed address family (a family
|
||||||
|
// change alters the socat target, so it has to be rebound below).
|
||||||
|
let stale: Vec<u16> = st
|
||||||
|
.forwards
|
||||||
|
.iter()
|
||||||
|
.filter(|(port, forward)| match discovered.get(port) {
|
||||||
|
None => true,
|
||||||
|
Some(_) if skip.contains(port) => true,
|
||||||
|
Some(family) => *family != forward.family,
|
||||||
|
})
|
||||||
|
.map(|(port, _)| *port)
|
||||||
|
.collect();
|
||||||
|
for port in stale {
|
||||||
|
if let Some(mut forward) = st.forwards.remove(&port) {
|
||||||
|
forward.shutdown().await;
|
||||||
|
log::info!("Auth bridge: released host port {}", port);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget conflicts for ports that are no longer relevant.
|
||||||
|
let before = st.conflicts.len();
|
||||||
|
st.conflicts
|
||||||
|
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
|
||||||
|
changed |= st.conflicts.len() != before;
|
||||||
|
|
||||||
|
for (&port, &family) in discovered {
|
||||||
|
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||||
|
Ok(forward) => {
|
||||||
|
if st.conflicts.remove(&port).is_some() {
|
||||||
|
log::info!("Auth bridge: host port {} became available", port);
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
|
||||||
|
port,
|
||||||
|
family.socat_target(port),
|
||||||
|
family
|
||||||
|
);
|
||||||
|
st.forwards.insert(port, forward);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Conflict policy: never fight for a port. Something else on the
|
||||||
|
// host owns it — another project's bridge, or an unrelated
|
||||||
|
// process. Skip it, record why so the UI can say so, and retry
|
||||||
|
// on later ticks in case the owner releases it. Warn only on
|
||||||
|
// the transition so a long-lived conflict doesn't spam the log.
|
||||||
|
let reason = format!(
|
||||||
|
"Host port {} is already in use ({}); not bridged.",
|
||||||
|
port, e
|
||||||
|
);
|
||||||
|
if st.conflicts.get(&port) != Some(&reason) {
|
||||||
|
log::warn!("Auth bridge: {}", reason);
|
||||||
|
st.conflicts.insert(port, reason);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||||
|
/// return nothing is bound.
|
||||||
|
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||||
|
let mut st = state.lock().await;
|
||||||
|
let forwards = std::mem::take(&mut st.forwards);
|
||||||
|
st.conflicts.clear();
|
||||||
|
let count = forwards.len();
|
||||||
|
for (_, mut forward) in forwards {
|
||||||
|
forward.shutdown().await;
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log::info!(
|
||||||
|
"Auth bridge: released {} host port(s) for project {}",
|
||||||
|
count,
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_status(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
state: &Arc<Mutex<BridgeState>>,
|
||||||
|
enabled: bool,
|
||||||
|
) {
|
||||||
|
let status = state.lock().await.snapshot(enabled);
|
||||||
|
let _ = app.emit(
|
||||||
|
AUTH_BRIDGE_EVENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"status": status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
|
let mut p = Project::new(
|
||||||
|
"test".to_string(),
|
||||||
|
vec![ProjectPath {
|
||||||
|
host_path: "/tmp".to_string(),
|
||||||
|
mount_name: "tmp".to_string(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
p.port_mappings = mappings
|
||||||
|
.into_iter()
|
||||||
|
.map(|(host_port, container_port)| PortMapping {
|
||||||
|
host_port,
|
||||||
|
container_port,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ports_already_published_by_docker_are_skipped() {
|
||||||
|
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||||
|
assert!(skip.contains(&3000));
|
||||||
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
|
// is already reachable, and the host port is Docker's binding.
|
||||||
|
assert!(skip.contains(&8080));
|
||||||
|
assert!(skip.contains(&8081));
|
||||||
|
assert!(!skip.contains(&34567));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_mappings_means_nothing_is_skipped() {
|
||||||
|
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
|
||||||
|
//! `/proc/net/tcp6` from inside the container.
|
||||||
|
//!
|
||||||
|
//! ## Why /proc and not `ss`
|
||||||
|
//!
|
||||||
|
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
|
||||||
|
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
|
||||||
|
//! and needs no package at all, so discovery works in the stock image and in any
|
||||||
|
//! snapshot derived from it.
|
||||||
|
//!
|
||||||
|
//! ## Wire format
|
||||||
|
//!
|
||||||
|
//! Both files are fixed-column text with a header line:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
|
||||||
|
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
|
||||||
|
//!
|
||||||
|
//! ## Hex and endianness
|
||||||
|
//!
|
||||||
|
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
|
||||||
|
//! the same way:
|
||||||
|
//!
|
||||||
|
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
|
||||||
|
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
|
||||||
|
//! order*, which is little-endian on every platform this app targets. So each
|
||||||
|
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
|
||||||
|
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
|
||||||
|
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
|
||||||
|
//!
|
||||||
|
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
|
||||||
|
//! digits), each converted independently, in order, to fill the 16 address
|
||||||
|
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
|
||||||
|
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
|
||||||
|
//!
|
||||||
|
//! ## What counts as loopback
|
||||||
|
//!
|
||||||
|
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
|
||||||
|
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
|
||||||
|
//! outside world — that is the port-mappings feature's job, not the auth
|
||||||
|
//! bridge's — so those rows are dropped.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The `st` column value for `TCP_LISTEN`.
|
||||||
|
const TCP_LISTEN: &str = "0A";
|
||||||
|
|
||||||
|
/// Which loopback address family (or families) a container-side listener was
|
||||||
|
/// found on. Determines the `socat` target address used to reach it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PortFamily {
|
||||||
|
/// Only `127.0.0.0/8`.
|
||||||
|
V4,
|
||||||
|
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
|
||||||
|
/// on Linux, so `claude login` frequently binds `::1` and nothing else
|
||||||
|
/// (anthropics/claude-code#44844).
|
||||||
|
V6,
|
||||||
|
/// Both — reachable either way; we use IPv4.
|
||||||
|
Dual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortFamily {
|
||||||
|
fn merge(self, other: PortFamily) -> PortFamily {
|
||||||
|
if self == other {
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
PortFamily::Dual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `socat` address that reaches this listener from inside the container.
|
||||||
|
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
|
||||||
|
/// (verified: connect gets ECONNREFUSED), hence the split.
|
||||||
|
pub fn socat_target(&self, port: u16) -> String {
|
||||||
|
match self {
|
||||||
|
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
|
||||||
|
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed LISTEN row that survived the loopback filter.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct LoopbackListener {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
|
||||||
|
/// the set of loopback ports being listened on, keyed by port with the families
|
||||||
|
/// merged (a port bound on both `127.0.0.1` and `::1` yields
|
||||||
|
/// [`PortFamily::Dual`]).
|
||||||
|
///
|
||||||
|
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
|
||||||
|
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
|
||||||
|
/// combined output — are silently ignored rather than failing the whole poll.
|
||||||
|
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
|
||||||
|
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
|
||||||
|
for listener in parse_listener_rows(text) {
|
||||||
|
ports
|
||||||
|
.entry(listener.port)
|
||||||
|
.and_modify(|f| *f = f.merge(listener.family))
|
||||||
|
.or_insert(listener.family);
|
||||||
|
}
|
||||||
|
ports
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row-level parse, before per-port family merging. Split out so tests can
|
||||||
|
/// assert on the individual rows.
|
||||||
|
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
|
||||||
|
text.lines().filter_map(parse_listener_row).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let _sl = fields.next()?;
|
||||||
|
let local_address = fields.next()?;
|
||||||
|
let _rem_address = fields.next()?;
|
||||||
|
let state = fields.next()?;
|
||||||
|
|
||||||
|
if state != TCP_LISTEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (addr_hex, port_hex) = local_address.split_once(':')?;
|
||||||
|
// The port is a straightforward big-endian hex u16 — no byte swapping.
|
||||||
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||||
|
if port == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match addr_hex.len() {
|
||||||
|
8 => {
|
||||||
|
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
|
||||||
|
addr.is_loopback().then_some(PortFamily::V4)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
|
||||||
|
let group = std::str::from_utf8(group).ok()?;
|
||||||
|
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
|
||||||
|
}
|
||||||
|
let addr = Ipv6Addr::from(octets);
|
||||||
|
// An IPv4-mapped row describes a v4 socket, so it is reachable at
|
||||||
|
// 127.0.0.1 and must be classified as v4, not v6.
|
||||||
|
match addr.to_ipv4_mapped() {
|
||||||
|
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
|
||||||
|
None => addr.is_loopback().then_some(PortFamily::V6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Some(LoopbackListener { port, family })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `%08X` procfs address word into its four address bytes in network
|
||||||
|
/// order. The kernel prints the word in host byte order, so the recovered bytes
|
||||||
|
/// are the little-endian expansion of the parsed integer.
|
||||||
|
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
|
||||||
|
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
|
||||||
|
/// with three listeners deliberately started:
|
||||||
|
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
|
||||||
|
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
|
||||||
|
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
|
||||||
|
const REAL_PROC_NET_TCP: &str = concat!(
|
||||||
|
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
|
||||||
|
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
|
||||||
|
/// the Node listener bound to `::1` only — the case that motivates the
|
||||||
|
/// TCP6 socat target.
|
||||||
|
const REAL_PROC_NET_TCP6: &str = concat!(
|
||||||
|
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||||
|
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
fn both_files() -> String {
|
||||||
|
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv4_loopback_row_with_little_endian_address() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
|
||||||
|
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 0x8707,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(rows[0].port, 34567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ipv6_loopback_row() {
|
||||||
|
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34568,
|
||||||
|
family: PortFamily::V6
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_wildcard_bind_addresses() {
|
||||||
|
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
|
||||||
|
// the port-mappings feature's territory.
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert!(!ports.contains_key(&34569));
|
||||||
|
|
||||||
|
// Same for the IPv6 wildcard and a non-loopback unicast address.
|
||||||
|
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(wildcard_v6).is_empty());
|
||||||
|
assert!(parse_listener_rows(lan_v4).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_both_files_concatenated_as_one_exec_output() {
|
||||||
|
let ports = parse_loopback_listeners(&both_files());
|
||||||
|
assert_eq!(ports.len(), 2);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
|
||||||
|
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_families_for_a_dual_stack_port() {
|
||||||
|
let dual = format!(
|
||||||
|
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
|
||||||
|
both_files()
|
||||||
|
);
|
||||||
|
let ports = parse_loopback_listeners(&dual);
|
||||||
|
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_mapped_loopback_is_reported_as_v4() {
|
||||||
|
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
|
||||||
|
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert_eq!(
|
||||||
|
parse_listener_rows(row),
|
||||||
|
vec![LoopbackListener {
|
||||||
|
port: 34567,
|
||||||
|
family: PortFamily::V4
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_listen_states() {
|
||||||
|
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
|
||||||
|
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||||
|
assert!(parse_listener_rows(established).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_headers_and_garbage() {
|
||||||
|
assert!(parse_listener_rows("").is_empty());
|
||||||
|
assert!(parse_listener_rows(
|
||||||
|
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
|
||||||
|
)
|
||||||
|
.is_empty());
|
||||||
|
// Truncated / malformed rows must not panic or be accepted.
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
|
||||||
|
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socat_target_matches_family() {
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::V4.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PortFamily::Dual.socat_target(34567),
|
||||||
|
"TCP:127.0.0.1:34567"
|
||||||
|
);
|
||||||
|
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Host-side loopback listener for one bridged port, and the per-connection
|
||||||
|
//! tunnel that carries its bytes into the container.
|
||||||
|
//!
|
||||||
|
//! ## Why not connect to the container's IP
|
||||||
|
//!
|
||||||
|
//! Container IPs are not routable from the host on Docker Desktop (macOS and
|
||||||
|
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
|
||||||
|
//! transport. The Docker API is the only channel guaranteed to reach the
|
||||||
|
//! container from the host, so each accepted connection is carried by a
|
||||||
|
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
|
||||||
|
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||||
|
//!
|
||||||
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
|
//! interactive terminal sessions are built on.
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
|
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||||
|
|
||||||
|
use super::proc_net::PortFamily;
|
||||||
|
|
||||||
|
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||||
|
/// only needs to not be pathological.
|
||||||
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
|
/// child running.
|
||||||
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
|
|
||||||
|
impl Drop for AbortOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One host loopback port bound and proxied into the container.
|
||||||
|
///
|
||||||
|
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
|
||||||
|
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
|
||||||
|
/// releases the port *and* tears down every connection under it. [`Drop`] does
|
||||||
|
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
|
||||||
|
/// also awaiting the aborted task, which guarantees the socket is closed before
|
||||||
|
/// the caller proceeds (important when a port is rebound right after).
|
||||||
|
pub struct PortForward {
|
||||||
|
pub port: u16,
|
||||||
|
pub family: PortFamily,
|
||||||
|
pub bridged_at: String,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PortForward {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortForward {
|
||||||
|
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||||
|
///
|
||||||
|
/// The bind happens before the task is spawned, so an already-taken port is
|
||||||
|
/// reported to the caller as an error rather than disappearing into a
|
||||||
|
/// background task.
|
||||||
|
pub async fn bind(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
family: PortFamily,
|
||||||
|
) -> Result<Self, std::io::Error> {
|
||||||
|
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
|
||||||
|
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
|
||||||
|
// an unauthenticated service inside the container that deliberately
|
||||||
|
// bound loopback because it expected to be reachable from nowhere else.
|
||||||
|
// Binding a wildcard address here would publish container internals to
|
||||||
|
// every host on the LAN. Do not "fix" a connectivity problem by
|
||||||
|
// widening these addresses.
|
||||||
|
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
|
||||||
|
|
||||||
|
// Also take ::1 when it is available. Browsers and CLIs resolve
|
||||||
|
// `localhost` to either family, and the IPv6 answer is often tried
|
||||||
|
// first, so a v4-only host listener would miss those callbacks. This is
|
||||||
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||||
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||||
|
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => Some(l),
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||||
|
port,
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = family.socat_target(port);
|
||||||
|
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
family,
|
||||||
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting, drop the host socket, and abort every in-flight
|
||||||
|
/// connection. Awaits the aborted task so the port is provably released
|
||||||
|
/// when this returns.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
let _ = (&mut self.task).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||||
|
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||||
|
async fn accept_loop(
|
||||||
|
container_id: String,
|
||||||
|
port: u16,
|
||||||
|
target: String,
|
||||||
|
v4: TcpListener,
|
||||||
|
v6: Option<TcpListener>,
|
||||||
|
) {
|
||||||
|
let mut conns: JoinSet<()> = JoinSet::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
r = v4.accept() => r,
|
||||||
|
r = accept_optional(v6.as_ref()) => r,
|
||||||
|
// Reap finished tunnels so the JoinSet doesn't grow without bound.
|
||||||
|
// When the set is empty `join_next()` yields None, the pattern fails
|
||||||
|
// to match, and the branch simply drops out of the select.
|
||||||
|
Some(_) = conns.join_next() => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, peer)) => {
|
||||||
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
conns.spawn(tunnel_connection(
|
||||||
|
container_id.clone(),
|
||||||
|
target.clone(),
|
||||||
|
stream,
|
||||||
|
port,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `accept()` on an optional listener; never completes when there is none, so it
|
||||||
|
/// can sit in a `select!` arm unconditionally.
|
||||||
|
async fn accept_optional(
|
||||||
|
listener: Option<&TcpListener>,
|
||||||
|
) -> std::io::Result<(TcpStream, SocketAddr)> {
|
||||||
|
match listener {
|
||||||
|
Some(l) => l.accept().await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry one accepted host connection into the container over `socat`.
|
||||||
|
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||||
|
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||||
|
|
||||||
|
let AttachedExec {
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
..
|
||||||
|
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
|
||||||
|
port,
|
||||||
|
target,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut host_rx, mut host_tx) = stream.into_split();
|
||||||
|
|
||||||
|
// Host → container. Runs as its own task so the container→host direction is
|
||||||
|
// never blocked behind a client that has stopped sending. Finishing this
|
||||||
|
// direction drops `input`, which closes the exec's stdin and lets socat see
|
||||||
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
||||||
|
let upstream = AbortOnDrop(tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
|
loop {
|
||||||
|
match host_rx.read(&mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
|
// stream ends, socat has exited and the connection is over.
|
||||||
|
while let Some(chunk) = output.next().await {
|
||||||
|
match chunk {
|
||||||
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
|
// diagnostics out of the proxied byte stream.
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if host_tx.write_all(&message).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: socat stderr for port {}: {}",
|
||||||
|
port,
|
||||||
|
String::from_utf8_lossy(&message).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = host_tx.shutdown().await;
|
||||||
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
|
drop(upstream);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! IPC surface for the auth bridge. The mechanism lives in
|
||||||
|
//! [`crate::auth_bridge`]; this file only translates between it and the
|
||||||
|
//! frontend, and keeps the persisted per-project flag in step.
|
||||||
|
|
||||||
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::auth_bridge::AuthBridgeStatus;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Turn the bridge on or off for a project and return the resulting status.
|
||||||
|
///
|
||||||
|
/// Enabling starts polling immediately when the container is already running;
|
||||||
|
/// otherwise the flag is simply persisted and `start_project_container` arms the
|
||||||
|
/// bridge on the next start. This is a host-side feature, so no container
|
||||||
|
/// recreation is involved either way.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auth_bridge_enabled(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, enabled)?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
if let Some(container_id) = project.container_id {
|
||||||
|
if crate::docker::container::is_container_running(&container_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id,
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Awaits the poller, so every host port is released before we return.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_bridge_status(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AuthBridgeStatus, String> {
|
||||||
|
let enabled = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.map(|p| p.auth_bridge_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok(state.auth_bridge.status(&project_id, enabled).await)
|
||||||
|
}
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
//! Shared Claude Code authentication — one long-lived token for every project.
|
||||||
|
//!
|
||||||
|
//! ## Why
|
||||||
|
//!
|
||||||
|
//! Without this, every container is its own authentication island: each one
|
||||||
|
//! needs `claude login`, each one opens a browser flow, each one stores its own
|
||||||
|
//! credential in its own config volume. `claude setup-token` mints a single
|
||||||
|
//! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`,
|
||||||
|
//! so one authentication event can cover the whole fleet.
|
||||||
|
//!
|
||||||
|
//! ## How the token is obtained
|
||||||
|
//!
|
||||||
|
//! Observed directly against Claude Code 2.1.226, because the flow is not what
|
||||||
|
//! the design assumed. `claude setup-token` prints an authorization URL whose
|
||||||
|
//! `redirect_uri` is **Anthropic-hosted**
|
||||||
|
//! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a
|
||||||
|
//! loopback listener. After signing in, the user copies a code off that page
|
||||||
|
//! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**.
|
||||||
|
//! It then prints the token.
|
||||||
|
//!
|
||||||
|
//! Two consequences:
|
||||||
|
//!
|
||||||
|
//! * The flow needs a way to deliver the pasted code, hence
|
||||||
|
//! [`submit_claude_token_code`] and the stdin channel below. Without it the
|
||||||
|
//! command would simply sit at the prompt until it timed out.
|
||||||
|
//! * [`crate::auth_bridge`] is *not* required for this particular command,
|
||||||
|
//! since there is no container-local callback to reach. It is still enabled
|
||||||
|
//! for the duration (and restored afterwards) as designed: it costs nothing
|
||||||
|
//! here and keeps the flow working if a future CLI version, or the plain
|
||||||
|
//! `claude login` path, goes back to a loopback redirect.
|
||||||
|
//!
|
||||||
|
//! ## Handling of the token itself
|
||||||
|
//!
|
||||||
|
//! The token never reaches the frontend. It is parsed out of the command's
|
||||||
|
//! output, written straight to the OS keychain, and from then on only
|
||||||
|
//! [`crate::docker::container`] reads it, to inject the env var. Everything
|
||||||
|
//! streamed to the UI passes through [`SecretRedactor`] first, and no command
|
||||||
|
//! here returns the token or accepts it as an argument.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
use crate::docker::container::is_container_running;
|
||||||
|
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
|
||||||
|
use crate::storage::secure;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Milestones in the acquisition flow. Payload `{ project_id, message }`,
|
||||||
|
/// matching the `container-progress` convention.
|
||||||
|
const PROGRESS_EVENT: &str = "claude-token-progress";
|
||||||
|
|
||||||
|
/// Redacted output from `claude setup-token`, so the UI can show the user the
|
||||||
|
/// URL to visit. Payload `{ project_id, chunk }`.
|
||||||
|
const OUTPUT_EVENT: &str = "claude-token-output";
|
||||||
|
|
||||||
|
/// How long to wait for the whole flow. Generous: the user has to switch to a
|
||||||
|
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
|
||||||
|
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
/// Documented shape of a `setup-token` credential.
|
||||||
|
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
|
||||||
|
|
||||||
|
/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be
|
||||||
|
/// believed. Real tokens run to ~90 characters; this is set well below that but
|
||||||
|
/// far above anything prose would produce, so documentation-style decoys like
|
||||||
|
/// `sk-ant-oat01-...` or `sk-ant-oat01-<your-token>` are rejected.
|
||||||
|
const MIN_TOKEN_BODY: usize = 32;
|
||||||
|
|
||||||
|
/// Redaction is deliberately broader than extraction: anything shaped like an
|
||||||
|
/// Anthropic credential is masked on its way to the UI, not just `oat01` ones.
|
||||||
|
const SECRET_MARKER: &str = "sk-ant-";
|
||||||
|
const SECRET_PLACEHOLDER: &str = "sk-ant-<redacted>";
|
||||||
|
const MIN_SECRET_BODY: usize = 8;
|
||||||
|
|
||||||
|
/// Cap on how much text [`SecretRedactor`] will withhold waiting for a
|
||||||
|
/// candidate secret to end. Past this, it is not a token — release it (still
|
||||||
|
/// redacted) rather than swallow the UI's output.
|
||||||
|
const MAX_HOLDBACK: usize = 4096;
|
||||||
|
|
||||||
|
/// Cap on the retained transcript used for parsing. The token is printed at the
|
||||||
|
/// end, and a re-rendering TUI can repaint many times, so keeping the tail is
|
||||||
|
/// both sufficient and bounded.
|
||||||
|
const MAX_TRANSCRIPT: usize = 256 * 1024;
|
||||||
|
|
||||||
|
/// Characters that can appear in the body of an Anthropic credential.
|
||||||
|
fn is_token_byte(b: u8) -> bool {
|
||||||
|
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Token extraction
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Pull the long-lived token out of `claude setup-token`'s output.
|
||||||
|
///
|
||||||
|
/// Strict by construction, because the alternative to failing is storing
|
||||||
|
/// garbage that silently breaks every container:
|
||||||
|
/// * the value must carry the documented `sk-ant-oat01-` prefix;
|
||||||
|
/// * the prefix must not be glued to the tail of a longer word;
|
||||||
|
/// * at least [`MIN_TOKEN_BODY`] token characters must follow it.
|
||||||
|
///
|
||||||
|
/// The **last** match wins. The command narrates before it succeeds, and a TUI
|
||||||
|
/// may repaint the same frame repeatedly, so earlier matches are either prose
|
||||||
|
/// or superseded repaints of the same value.
|
||||||
|
pub fn parse_setup_token(output: &str) -> Option<String> {
|
||||||
|
let bytes = output.as_bytes();
|
||||||
|
let mut found = None;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
|
||||||
|
while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) {
|
||||||
|
let start = cursor + offset;
|
||||||
|
cursor = start + TOKEN_PREFIX.len();
|
||||||
|
|
||||||
|
// `xsk-ant-oat01-…` is not a token, it is a substring of something else.
|
||||||
|
if start > 0 && is_token_byte(bytes[start - 1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_start = start + TOKEN_PREFIX.len();
|
||||||
|
let mut end = body_start;
|
||||||
|
while end < bytes.len() && is_token_byte(bytes[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end - body_start < MIN_TOKEN_BODY {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = Some(output[start..end].to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Redaction
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Mask every *complete* credential in `text`.
|
||||||
|
fn redact_complete(text: &str) -> String {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut copied = 0usize;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
|
||||||
|
while let Some(offset) = text[cursor..].find(SECRET_MARKER) {
|
||||||
|
let start = cursor + offset;
|
||||||
|
cursor = start + SECRET_MARKER.len();
|
||||||
|
|
||||||
|
if start > 0 && is_token_byte(bytes[start - 1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let body_start = start + SECRET_MARKER.len();
|
||||||
|
let mut end = body_start;
|
||||||
|
while end < bytes.len() && is_token_byte(bytes[end]) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end - body_start < MIN_SECRET_BODY {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&text[copied..start]);
|
||||||
|
out.push_str(SECRET_PLACEHOLDER);
|
||||||
|
copied = end;
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&text[copied..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the tail that might still grow into a credential begins. Everything
|
||||||
|
/// before this index is safe to emit; everything from it must be withheld until
|
||||||
|
/// more input arrives. Returns `text.len()` when nothing needs withholding.
|
||||||
|
fn holdback_index(text: &str) -> usize {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
|
||||||
|
// A credential already under way: the last marker with nothing but token
|
||||||
|
// characters after it. If the *last* marker fails that test, no earlier one
|
||||||
|
// can pass it either — the disqualifying character lies after them all.
|
||||||
|
if let Some(start) = text.rfind(SECRET_MARKER) {
|
||||||
|
let clean_start = start == 0 || !is_token_byte(bytes[start - 1]);
|
||||||
|
let body_all_token = bytes[start + SECRET_MARKER.len()..]
|
||||||
|
.iter()
|
||||||
|
.all(|b| is_token_byte(*b));
|
||||||
|
if clean_start && body_all_token {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: a marker truncated mid-way by the chunk boundary.
|
||||||
|
for len in (1..SECRET_MARKER.len()).rev() {
|
||||||
|
if text.len() >= len && text.is_char_boundary(text.len() - len)
|
||||||
|
&& &text[text.len() - len..] == &SECRET_MARKER[..len]
|
||||||
|
{
|
||||||
|
return text.len() - len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masks credentials out of a stream, tolerating a secret split across chunk
|
||||||
|
/// boundaries by withholding any tail that could still turn into one.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SecretRedactor {
|
||||||
|
pending: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretRedactor {
|
||||||
|
/// Absorb `chunk` and return the text that is now safe to show.
|
||||||
|
fn push(&mut self, chunk: &str) -> String {
|
||||||
|
self.pending.push_str(chunk);
|
||||||
|
|
||||||
|
let mut split = holdback_index(&self.pending);
|
||||||
|
if self.pending.len() - split > MAX_HOLDBACK {
|
||||||
|
split = self.pending.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
let emit = redact_complete(&self.pending[..split]);
|
||||||
|
self.pending.drain(..split);
|
||||||
|
emit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release whatever is still withheld. The stream is over, so a partial
|
||||||
|
/// credential can no longer grow — but it is still redacted on the way out.
|
||||||
|
fn flush(&mut self) -> String {
|
||||||
|
let out = redact_complete(&self.pending);
|
||||||
|
self.pending.clear();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Terminal control-sequence stripping
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Length in bytes of the UTF-8 character starting with `b`.
|
||||||
|
fn utf8_len(b: u8) -> usize {
|
||||||
|
if b < 0x80 {
|
||||||
|
1
|
||||||
|
} else if b >> 5 == 0b110 {
|
||||||
|
2
|
||||||
|
} else if b >> 4 == 0b1110 {
|
||||||
|
3
|
||||||
|
} else if b >> 3 == 0b11110 {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSI final bytes that move the cursor. Claude Code's TUI lays text out by
|
||||||
|
/// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting
|
||||||
|
/// these outright would weld neighbouring words together — which at best
|
||||||
|
/// garbles the URL the user has to read, and at worst welds a preceding word
|
||||||
|
/// onto the token and makes the parser reject it. They become a space instead:
|
||||||
|
/// a separator can never fabricate or destroy a match.
|
||||||
|
const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd";
|
||||||
|
|
||||||
|
/// Strip terminal control sequences from the front of `bytes`, stopping at the
|
||||||
|
/// first incomplete sequence or truncated character. Returns the clean text and
|
||||||
|
/// how many bytes were consumed.
|
||||||
|
fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) {
|
||||||
|
let mut out = String::with_capacity(bytes.len());
|
||||||
|
let mut i = 0usize;
|
||||||
|
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
0x1b => {
|
||||||
|
if i + 1 >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
match bytes[i + 1] {
|
||||||
|
// CSI: parameter/intermediate bytes, then a final 0x40..=0x7e.
|
||||||
|
b'[' => {
|
||||||
|
let mut j = i + 2;
|
||||||
|
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if CURSOR_MOVE_FINALS.contains(&bytes[j]) {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
// OSC: runs until BEL or ST (ESC \).
|
||||||
|
b']' => {
|
||||||
|
let mut j = i + 2;
|
||||||
|
loop {
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j] == 0x07 {
|
||||||
|
j += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if bytes[j] == 0x1b {
|
||||||
|
if j + 1 >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j + 1] == b'\\' {
|
||||||
|
j += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
// Two-byte escapes (charset selection, keypad mode, …).
|
||||||
|
_ => i += 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A repaint returns to column 0. Turn that into a line break so the
|
||||||
|
// old frame's trailing text cannot be glued onto the new frame's
|
||||||
|
// leading text — which could otherwise fabricate a "token". A run of
|
||||||
|
// CRs immediately before a LF is just the pty's ONLCR translation,
|
||||||
|
// so it collapses into that single LF rather than blank lines.
|
||||||
|
b'\r' => {
|
||||||
|
let mut j = i;
|
||||||
|
while j < bytes.len() && bytes[j] == b'\r' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j >= bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if bytes[j] != b'\n' {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
b'\n' => {
|
||||||
|
out.push('\n');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'\t' => {
|
||||||
|
out.push('\t');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
0x00..=0x1f | 0x7f => i += 1,
|
||||||
|
b => {
|
||||||
|
let len = utf8_len(b);
|
||||||
|
if i + len > bytes.len() {
|
||||||
|
return (out, i);
|
||||||
|
}
|
||||||
|
if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) {
|
||||||
|
out.push_str(s);
|
||||||
|
}
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(out, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
|
||||||
|
/// trailing sequence over to the next chunk.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AnsiStripper {
|
||||||
|
carry: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiStripper {
|
||||||
|
fn push(&mut self, chunk: &[u8]) -> String {
|
||||||
|
self.carry.extend_from_slice(chunk);
|
||||||
|
let (out, consumed) = strip_ansi_prefix(&self.carry);
|
||||||
|
self.carry.drain(..consumed);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Commands
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`]
|
||||||
|
/// can answer the CLI's `Paste code here` prompt.
|
||||||
|
///
|
||||||
|
/// `Some` exactly while a flow is running, which doubles as the single-flight
|
||||||
|
/// guard: the token is global, so two concurrent logins would race to overwrite
|
||||||
|
/// each other's keychain entry and neither could tell which prompt it was
|
||||||
|
/// feeding.
|
||||||
|
static PENDING_INPUT: OnceLock<Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
|
||||||
|
PENDING_INPUT.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
PROGRESS_EVENT,
|
||||||
|
serde_json::json!({ "project_id": project_id, "message": message }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
OUTPUT_EVENT,
|
||||||
|
serde_json::json!({ "project_id": project_id, "chunk": chunk }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell run inside the container.
|
||||||
|
///
|
||||||
|
/// * `stty` widens the pty before Claude Code starts, so its layout engine does
|
||||||
|
/// not wrap the token or the sign-in URL across lines. Docker's default exec
|
||||||
|
/// pty is 80 columns; both are longer than that. Setting it here rather than
|
||||||
|
/// via a post-start resize avoids racing the process's startup.
|
||||||
|
/// * The `unset` line strips inherited auth so `setup-token` runs against a
|
||||||
|
/// clean claude.ai login instead of warning about, or deferring to, whatever
|
||||||
|
/// credential the container is already configured with — including a shared
|
||||||
|
/// token from a previous run, which is likely the very thing being replaced.
|
||||||
|
const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 200 rows 50 2>/dev/null || true
|
||||||
|
unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \
|
||||||
|
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
exec claude setup-token"#;
|
||||||
|
|
||||||
|
/// Run `claude setup-token` in the container and return the token it printed.
|
||||||
|
/// Streams redacted output as it arrives and forwards anything arriving on
|
||||||
|
/// `input_rx` (the user's pasted code) to the command's stdin.
|
||||||
|
async fn run_setup_token(
|
||||||
|
app: &AppHandle,
|
||||||
|
project_id: &str,
|
||||||
|
container_id: &str,
|
||||||
|
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
|
||||||
|
// reads the pasted code in raw mode, which a plain pipe cannot provide.
|
||||||
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
mut output,
|
||||||
|
mut input,
|
||||||
|
} = create_attached_exec(
|
||||||
|
container_id,
|
||||||
|
vec![
|
||||||
|
"sh".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
SETUP_TOKEN_SCRIPT.to_string(),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut stripper = AnsiStripper::default();
|
||||||
|
let mut redactor = SecretRedactor::default();
|
||||||
|
let mut transcript = String::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Writing stdin and reading stdout are driven from the same loop: with
|
||||||
|
// a hijacked exec both halves ride one socket, and `input` must stay
|
||||||
|
// alive for the whole session anyway — dropping it early would tear the
|
||||||
|
// output stream down with it.
|
||||||
|
let next = tokio::select! {
|
||||||
|
Some(data) = input_rx.recv() => {
|
||||||
|
if let Err(e) = input.write_all(&data).await {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not send the code to `claude setup-token`: {}. No token was stored.",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = input.flush().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next = tokio::time::timeout_at(deadline, output.next()) => match next {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Timed out after {} minutes waiting for `claude setup-token` to finish. \
|
||||||
|
No token was stored.",
|
||||||
|
SETUP_TIMEOUT.as_secs() / 60
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = match next {
|
||||||
|
Some(Ok(frame)) => frame,
|
||||||
|
Some(Err(e)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Lost the connection to `claude setup-token`: {}. No token was stored.",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible = stripper.push(&frame.into_bytes());
|
||||||
|
if visible.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript.push_str(&visible);
|
||||||
|
if transcript.len() > MAX_TRANSCRIPT {
|
||||||
|
// Keep the tail: that is where the token lands.
|
||||||
|
let cut = transcript.len() - MAX_TRANSCRIPT / 2;
|
||||||
|
let cut = (cut..transcript.len())
|
||||||
|
.find(|i| transcript.is_char_boundary(*i))
|
||||||
|
.unwrap_or(transcript.len());
|
||||||
|
transcript.drain(..cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
let safe = redactor.push(&visible);
|
||||||
|
if !safe.is_empty() {
|
||||||
|
emit_output(app, project_id, &safe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tail = redactor.flush();
|
||||||
|
if !tail.is_empty() {
|
||||||
|
emit_output(app, project_id, &tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit_code = wait_for_exec_exit(&exec_id).await.unwrap_or(0);
|
||||||
|
if exit_code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"`claude setup-token` exited with status {}. No token was stored — \
|
||||||
|
see the command output above for what went wrong.",
|
||||||
|
exit_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_setup_token(&transcript).ok_or_else(|| {
|
||||||
|
"`claude setup-token` finished but printed no recognisable token. \
|
||||||
|
Nothing was stored. This usually means the login was cancelled, or the \
|
||||||
|
account has no Claude subscription (long-lived tokens require one)."
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a shared, long-lived Claude Code token by running `claude setup-token`
|
||||||
|
/// inside `project_id`'s container, and store it in the OS keychain.
|
||||||
|
///
|
||||||
|
/// The project only lends its container — a place to run the CLI that already
|
||||||
|
/// has Claude Code installed. The resulting token is global, and is used by
|
||||||
|
/// every Anthropic-backend project that has not opted out.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn acquire_claude_token(
|
||||||
|
project_id: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Project '{}' has no container yet. Start it, then run authentication again.",
|
||||||
|
project.name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||||
|
return Err(format!(
|
||||||
|
"The container for '{}' is not running. Start it, then run authentication again.",
|
||||||
|
project.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the flow before touching anything else, so a second caller bounces
|
||||||
|
// off the guard rather than half-configuring the same project.
|
||||||
|
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
|
{
|
||||||
|
let mut slot = pending_input().lock().await;
|
||||||
|
if slot.is_some() {
|
||||||
|
return Err(
|
||||||
|
"A Claude authentication flow is already running. Finish or cancel it first."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*slot = Some(input_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bridge_was_enabled = project.auth_bridge_enabled;
|
||||||
|
|
||||||
|
let result = async {
|
||||||
|
// See the module docs: 2.1.226's `setup-token` redirects to an
|
||||||
|
// Anthropic-hosted callback, so no container-local listener needs
|
||||||
|
// bridging. Enabled anyway, per design, to cover CLI versions and login
|
||||||
|
// paths that do use a loopback redirect. Temporary elevation — the
|
||||||
|
// prior setting is restored below whatever happens.
|
||||||
|
if !bridge_was_enabled {
|
||||||
|
state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, true)?;
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Auth bridge enabled for the duration of login.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Called unconditionally, and idempotent: the flag may already have
|
||||||
|
// been on while the poller was not running (e.g. enabled before start).
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
|
||||||
|
);
|
||||||
|
|
||||||
|
run_setup_token(&app_handle, &project_id, &container_id, input_rx).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Release the flow, then restore the bridge — both unconditionally, so a
|
||||||
|
// failed or cancelled login leaves nothing latched on.
|
||||||
|
*pending_input().lock().await = None;
|
||||||
|
if !bridge_was_enabled {
|
||||||
|
// Stop the poller first: it awaits teardown, so host ports are provably
|
||||||
|
// released before the flag goes back.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
if let Err(e) = state
|
||||||
|
.projects_store
|
||||||
|
.set_auth_bridge_enabled(&project_id, false)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to restore the auth bridge setting for project {}: {}",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = result?;
|
||||||
|
secure::store_claude_oauth_token(&token)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Stored a shared Claude authentication token (acquired via project {})",
|
||||||
|
project_id
|
||||||
|
);
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
"Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the `Paste code here if prompted >` prompt of a running
|
||||||
|
/// [`acquire_claude_token`] with the code shown after signing in.
|
||||||
|
///
|
||||||
|
/// Takes no project id: the flow is single-flight and the token is global, so
|
||||||
|
/// there is only ever one prompt waiting.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
|
||||||
|
let code = code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err("Enter the code shown after signing in.".to_string());
|
||||||
|
}
|
||||||
|
// The code goes to a TUI text input. A newline or escape embedded in it
|
||||||
|
// would submit early or drive the widget, so reject control characters
|
||||||
|
// outright rather than trying to sanitise them.
|
||||||
|
if code.chars().any(char::is_control) {
|
||||||
|
return Err("That code contains invalid characters. Copy it again and retry.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = pending_input().lock().await;
|
||||||
|
let sender = slot.as_ref().ok_or_else(|| {
|
||||||
|
"No Claude authentication flow is waiting for a code. Start authentication first."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut keystrokes = code.as_bytes().to_vec();
|
||||||
|
keystrokes.push(b'\r');
|
||||||
|
sender
|
||||||
|
.send(keystrokes)
|
||||||
|
.map_err(|_| "The authentication flow has already ended.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shared Claude token exists. Deliberately a boolean — no command
|
||||||
|
/// here ever hands the token itself to the frontend.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_claude_token() -> Result<bool, String> {
|
||||||
|
Ok(secure::has_claude_oauth_token())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the shared Claude token. Containers keep the injected value until
|
||||||
|
/// each is next started, at which point the rotation-id label mismatch forces a
|
||||||
|
/// recreation that blanks the env var.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_claude_token() -> Result<(), String> {
|
||||||
|
secure::delete_claude_oauth_token()?;
|
||||||
|
log::info!("Cleared the shared Claude authentication token");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A token-shaped value of realistic length.
|
||||||
|
fn token(seed: char) -> String {
|
||||||
|
format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_the_token_from_realistic_output() {
|
||||||
|
let tok = token('A');
|
||||||
|
let output = format!(
|
||||||
|
"Claude Code long-lived token setup\n\
|
||||||
|
Opening browser to https://claude.ai/oauth/authorize?code=true\n\
|
||||||
|
Login successful!\n\n\
|
||||||
|
Your token:\n{}\n\n\
|
||||||
|
Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_prose_decoys_and_still_finds_the_real_token() {
|
||||||
|
let tok = token('B');
|
||||||
|
let output = format!(
|
||||||
|
"Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\
|
||||||
|
or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-<your-token-here>\n\
|
||||||
|
Your token: {}\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decoy_on_its_own_yields_nothing() {
|
||||||
|
let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n";
|
||||||
|
assert_eq!(parse_setup_token(output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_match_returns_none_rather_than_guessing() {
|
||||||
|
assert_eq!(parse_setup_token(""), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_token("error: authentication cancelled by the user\n"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Right length, wrong product prefix.
|
||||||
|
assert_eq!(
|
||||||
|
parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_matches_take_the_last() {
|
||||||
|
let old = token('D');
|
||||||
|
let new = token('E');
|
||||||
|
let output = format!(
|
||||||
|
"Replacing existing token {}\n...\nYour new token: {}\n",
|
||||||
|
old, new
|
||||||
|
);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repainted_tui_frame_yields_the_same_token_once() {
|
||||||
|
let tok = token('F');
|
||||||
|
// Same frame drawn three times, as a TUI would.
|
||||||
|
let output = format!("Your token: {}\n", tok).repeat(3);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prefix_glued_to_a_longer_word_is_not_a_token() {
|
||||||
|
let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90));
|
||||||
|
assert_eq!(parse_setup_token(&output), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_stops_at_the_first_non_token_character() {
|
||||||
|
let tok = token('H');
|
||||||
|
let output = format!("token=\"{}\", expires=2027-08-09\n", tok);
|
||||||
|
assert_eq!(parse_setup_token(&output), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_masks_a_token_in_one_piece() {
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = r.push(&format!("Your token: {}\n", token('I')));
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert!(!seen.contains(TOKEN_PREFIX));
|
||||||
|
assert!(seen.contains(SECRET_PLACEHOLDER));
|
||||||
|
assert!(seen.contains("Your token: "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_survives_a_token_split_across_chunks() {
|
||||||
|
let tok = token('J');
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = String::new();
|
||||||
|
// Split mid-prefix and again mid-body — the worst case for a naive
|
||||||
|
// per-chunk regex.
|
||||||
|
seen.push_str(&r.push("Your token: sk-a"));
|
||||||
|
seen.push_str(&r.push(&tok[4..40]));
|
||||||
|
seen.push_str(&r.push(&tok[40..]));
|
||||||
|
seen.push_str(&r.push("\ndone\n"));
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen);
|
||||||
|
assert!(seen.contains(SECRET_PLACEHOLDER));
|
||||||
|
assert!(seen.ends_with("\ndone\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_leaves_ordinary_text_alone() {
|
||||||
|
let mut r = SecretRedactor::default();
|
||||||
|
let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n");
|
||||||
|
seen.push_str(&r.flush());
|
||||||
|
assert_eq!(
|
||||||
|
seen,
|
||||||
|
"Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_recovers_the_token_from_a_styled_frame() {
|
||||||
|
let tok = token('K');
|
||||||
|
let framed = format!(
|
||||||
|
"\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n",
|
||||||
|
tok
|
||||||
|
);
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(framed.as_bytes());
|
||||||
|
assert!(!visible.contains('\x1b'));
|
||||||
|
assert_eq!(parse_setup_token(&visible), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Code's TUI positions words with `ESC [ n G` instead of spaces
|
||||||
|
/// (verified against 2.1.226). Deleting those would weld words together.
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_turns_column_jumps_into_separators() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m");
|
||||||
|
assert_eq!(visible, "Welcome to Claude Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure this protects against: a column jump immediately before the
|
||||||
|
/// token would, if simply deleted, glue the preceding word onto the prefix
|
||||||
|
/// and make `parse_setup_token` reject a perfectly good token.
|
||||||
|
#[test]
|
||||||
|
fn a_column_jump_before_the_token_does_not_hide_it() {
|
||||||
|
let tok = token('L');
|
||||||
|
let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok);
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(framed.as_bytes());
|
||||||
|
// The leading jump is an indent, so it becomes a space too.
|
||||||
|
assert_eq!(visible, format!(" Token {}\n", tok));
|
||||||
|
assert_eq!(parse_setup_token(&visible), Some(tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pty with ONLCR emits `\r\r\n` at end of line; that is one break.
|
||||||
|
#[test]
|
||||||
|
fn carriage_return_runs_before_a_newline_collapse() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"one\r\r\ntwo\r\r\n");
|
||||||
|
assert_eq!(visible, "one\ntwo\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare CR is a repaint, and must still break the line so the old frame's
|
||||||
|
/// tail cannot be welded onto the new frame's head.
|
||||||
|
#[test]
|
||||||
|
fn a_bare_carriage_return_breaks_the_line() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new");
|
||||||
|
assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_removes_osc8_hyperlink_wrappers() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07");
|
||||||
|
assert_eq!(visible, "https://claude.com/x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_stripping_handles_a_sequence_split_across_chunks() {
|
||||||
|
let mut s = AnsiStripper::default();
|
||||||
|
let mut visible = s.push(b"a\x1b[3");
|
||||||
|
visible.push_str(&s.push(b"1mb"));
|
||||||
|
assert_eq!(visible, "ab");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod auth_bridge_commands;
|
||||||
|
pub mod auth_token_commands;
|
||||||
pub mod aws_commands;
|
pub mod aws_commands;
|
||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ pub async fn remove_project(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Release any host loopback ports the auth bridge holds for this project
|
||||||
|
// before the container (and the project record) go away.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists
|
||||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
@@ -133,10 +137,35 @@ pub async fn remove_project(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_project(
|
pub async fn update_project(
|
||||||
project: Project,
|
project: Project,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<Project, String> {
|
||||||
store_secrets_for_project(&project)?;
|
store_secrets_for_project(&project)?;
|
||||||
state.projects_store.update(project)
|
let updated = state.projects_store.update(project)?;
|
||||||
|
|
||||||
|
// `auth_bridge_enabled` can arrive through this generic save as well as
|
||||||
|
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
|
||||||
|
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
|
||||||
|
// when nothing is running, so this is safe on every project save.
|
||||||
|
if updated.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = updated.container_id {
|
||||||
|
if docker::is_container_running(container_id).await.unwrap_or(false) {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
updated.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle,
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.auth_bridge.stop(&updated.id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -400,6 +429,20 @@ pub async fn start_project_container(
|
|||||||
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)?;
|
||||||
|
|
||||||
|
// Arm the auth bridge if this project opted in. Purely host-side, so it
|
||||||
|
// happens after the container is up and never affects the start itself.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project_id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
project.container_id = Some(container_id);
|
project.container_id = Some(container_id);
|
||||||
project.status = ProjectStatus::Running;
|
project.status = ProjectStatus::Running;
|
||||||
Ok(project)
|
Ok(project)
|
||||||
@@ -418,6 +461,9 @@ pub async fn stop_project_container(
|
|||||||
|
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
|
||||||
|
|
||||||
|
// Drop host listeners first: they only make sense while the container runs.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
// Close exec sessions for this project
|
// Close exec sessions for this project
|
||||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||||
@@ -443,6 +489,10 @@ pub async fn rebuild_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))?;
|
||||||
|
|
||||||
|
// The bridge is bound to the container that is about to be destroyed;
|
||||||
|
// `start_project_container` below re-arms it against the new one.
|
||||||
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// 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_sessions_for_container(container_id).await;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
@@ -469,6 +519,7 @@ pub async fn rebuild_project_container(
|
|||||||
/// to Stopped.
|
/// to Stopped.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reconcile_project_statuses(
|
pub async fn reconcile_project_statuses(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Project>, String> {
|
) -> Result<Vec<Project>, String> {
|
||||||
let projects = state.projects_store.list();
|
let projects = state.projects_store.list();
|
||||||
@@ -490,6 +541,22 @@ pub async fn reconcile_project_statuses(
|
|||||||
project.name,
|
project.name,
|
||||||
project.id
|
project.id
|
||||||
);
|
);
|
||||||
|
// The app may have restarted while the container kept running; the
|
||||||
|
// bridge lives in this process, so re-arm it here. `start` is
|
||||||
|
// idempotent, so a bridge that is already polling is untouched.
|
||||||
|
if project.auth_bridge_enabled {
|
||||||
|
if let Some(ref container_id) = project.container_id {
|
||||||
|
state
|
||||||
|
.auth_bridge
|
||||||
|
.start(
|
||||||
|
project.id.clone(),
|
||||||
|
container_id.clone(),
|
||||||
|
app_handle.clone(),
|
||||||
|
state.projects_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Project '{}' ({}) container is not running — setting to Stopped",
|
"Project '{}' ({}) container is not running — setting to Stopped",
|
||||||
|
|||||||
@@ -171,23 +171,45 @@ fn build_claude_instructions(
|
|||||||
combined
|
combined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The env var Claude Code reads a long-lived `claude setup-token` credential
|
||||||
|
/// from. Named once so injection, the reserved-name blocklist, and the
|
||||||
|
/// stale-value neutralization pass can never disagree about the spelling.
|
||||||
|
pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||||
|
|
||||||
|
/// Env var name prefixes Triple-C manages itself; users cannot set these by hand.
|
||||||
|
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
|
|
||||||
|
/// Exact env var names Triple-C manages itself. Not covered by
|
||||||
|
/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes.
|
||||||
|
///
|
||||||
|
/// `MCP_SERVERS_JSON` is reserved for legacy reasons: the built-in MCP feature
|
||||||
|
/// was removed, but the name stays blocked so users cannot hand-set it.
|
||||||
|
/// `CLAUDE_CODE_OAUTH_TOKEN` is reserved because Triple-C owns it — a hand-set
|
||||||
|
/// value would silently outrank the keychain-held shared token and be invisible
|
||||||
|
/// to the auth UI.
|
||||||
|
const RESERVED_ENV_EXACT: &[&str] = &[
|
||||||
|
"CLAUDE_INSTRUCTIONS",
|
||||||
|
"MCP_SERVERS_JSON",
|
||||||
|
"CLAUDE_CODE_SETTINGS_JSON",
|
||||||
|
"MISSION_CONTROL_ENABLED",
|
||||||
|
"TRIPLE_C_PERMISSION_MODE",
|
||||||
|
CLAUDE_OAUTH_TOKEN_ENV,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether `key` is an env var name Triple-C reserves for itself.
|
||||||
|
fn is_reserved_env_key(key: &str) -> bool {
|
||||||
|
let upper = key.to_uppercase();
|
||||||
|
RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p))
|
||||||
|
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute a fingerprint string for the custom environment variables.
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
|
||||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
|
||||||
// removed, but the name stays blocked so users cannot hand-set it.
|
|
||||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"];
|
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
for env_var in custom_env_vars {
|
for env_var in custom_env_vars {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() || is_reserved_env_key(key) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let upper = key.to_uppercase();
|
|
||||||
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|
|
||||||
|| reserved_exact.iter().any(|e| upper == *e);
|
|
||||||
if is_reserved {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
parts.push(format!("{}={}", key, env_var.value));
|
parts.push(format!("{}={}", key, env_var.value));
|
||||||
@@ -196,6 +218,45 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
|||||||
parts.join(",")
|
parts.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shared Claude Code OAuth token to inject for this project, paired with
|
||||||
|
/// its rotation id.
|
||||||
|
///
|
||||||
|
/// `None` unless *all* of: the backend is Anthropic (the token is meaningless
|
||||||
|
/// to Bedrock/Ollama/OpenAI-compatible), the project has not opted out, and a
|
||||||
|
/// non-blank token is actually in the keychain. Read here rather than passed in
|
||||||
|
/// because the token is global, not part of the per-project record.
|
||||||
|
///
|
||||||
|
/// The returned token is never logged and never leaves this module except as
|
||||||
|
/// the env var value handed to Docker.
|
||||||
|
fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
|
||||||
|
if project.backend != Backend::Anthropic || !project.use_shared_auth_token {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = crate::storage::secure::get_claude_oauth_token()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("Could not read the shared Claude token from the keychain: {}", e);
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.filter(|t| !t.trim().is_empty())?;
|
||||||
|
// A token with no rotation id predates versioning (or the id write failed).
|
||||||
|
// A constant stand-in still differs from the empty "no token" label, so
|
||||||
|
// presence changes are caught; only rotations could be missed.
|
||||||
|
let version = crate::storage::secure::get_claude_oauth_token_version()
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or_else(|| "unversioned".to_string());
|
||||||
|
Some((token, version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label value tracking which shared Claude token (if any) a container was
|
||||||
|
/// created with. Empty means "none injected". See
|
||||||
|
/// [`crate::storage::secure`] for why this is a random rotation id rather than
|
||||||
|
/// a hash of the token.
|
||||||
|
fn claude_token_label(project: &Project) -> String {
|
||||||
|
shared_claude_auth(project)
|
||||||
|
.map(|(_, version)| version)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge global and per-project custom environment variables.
|
/// Merge global and per-project custom environment variables.
|
||||||
/// Per-project variables override global variables with the same key.
|
/// Per-project variables override global variables with the same key.
|
||||||
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||||
@@ -680,6 +741,19 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
|
||||||
|
// project). Injected *before* the neutralization pass below so that pass
|
||||||
|
// sees it as already-set; when it is absent the pass actively blanks the
|
||||||
|
// variable instead of leaving a stale one baked into the snapshot image.
|
||||||
|
let shared_claude = shared_claude_auth(project);
|
||||||
|
if let Some((ref token, _)) = shared_claude {
|
||||||
|
env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token));
|
||||||
|
log::info!(
|
||||||
|
"Injecting the shared Claude authentication token into the container for project {}",
|
||||||
|
project.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||||
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||||
// is recreated *from a snapshot image* committed off the previous container.
|
// is recreated *from a snapshot image* committed off the previous container.
|
||||||
@@ -704,6 +778,11 @@ pub async fn create_container(
|
|||||||
"ANTHROPIC_MODEL",
|
"ANTHROPIC_MODEL",
|
||||||
"DISABLE_PROMPT_CACHING",
|
"DISABLE_PROMPT_CACHING",
|
||||||
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
||||||
|
// Revoking the shared token, opting a project out, or switching away
|
||||||
|
// from the Anthropic backend must *clear* this, not merely stop setting
|
||||||
|
// it — otherwise the value committed into the snapshot image keeps
|
||||||
|
// authenticating the container with a credential the user removed.
|
||||||
|
CLAUDE_OAUTH_TOKEN_ENV,
|
||||||
];
|
];
|
||||||
let already_set: std::collections::HashSet<String> = env_vars
|
let already_set: std::collections::HashSet<String> = env_vars
|
||||||
.iter()
|
.iter()
|
||||||
@@ -717,19 +796,12 @@ pub async fn create_container(
|
|||||||
|
|
||||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
|
||||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
|
||||||
// removed, but the name stays blocked so users cannot hand-set it.
|
|
||||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"];
|
|
||||||
for env_var in &merged_env {
|
for env_var in &merged_env {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let upper = key.to_uppercase();
|
if is_reserved_env_key(key) {
|
||||||
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|
|
||||||
|| reserved_exact.iter().any(|e| upper == *e);
|
|
||||||
if is_reserved {
|
|
||||||
log::warn!("Skipping reserved env var: {}", key);
|
log::warn!("Skipping reserved env var: {}", key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -948,6 +1020,10 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
|
labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
|
||||||
labels.insert("triple-c.git-token-hash".to_string(),
|
labels.insert("triple-c.git-token-hash".to_string(),
|
||||||
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
||||||
|
// Rotation id, NOT the token and NOT a hash of it — labels are readable by
|
||||||
|
// anything on the host via `docker inspect`.
|
||||||
|
labels.insert("triple-c.claude-token-version".to_string(),
|
||||||
|
shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
@@ -1144,7 +1220,8 @@ chmod 600 "$HOME/.aws/credentials""#;
|
|||||||
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
|
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
|
||||||
/// the resulting image — passing an empty Config here does NOT strip it, and
|
/// the resulting image — passing an empty Config here does NOT strip it, and
|
||||||
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
|
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
|
||||||
/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV.
|
/// CLAUDE_CODE_USE_BEDROCK, AWS_*, CLAUDE_CODE_OAUTH_TOKEN) are present in this
|
||||||
|
/// snapshot image's ENV — this image is local and per-project, never pushed.
|
||||||
/// `create_container` defends against that by explicitly overriding every
|
/// `create_container` defends against that by explicitly overriding every
|
||||||
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
|
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
|
||||||
/// backend switch does not inherit the previous backend's stale credentials.
|
/// backend switch does not inherit the previous backend's stale credentials.
|
||||||
@@ -1390,6 +1467,20 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared Claude Code OAuth token ───────────────────────────────────
|
||||||
|
// Compares rotation ids, so this fires when the token is first acquired,
|
||||||
|
// re-acquired (rotated), revoked, or opted out of. Both "" means no token
|
||||||
|
// is in play, which is also what a container predating this feature reports
|
||||||
|
// — so existing installs are not recreated until a token actually exists.
|
||||||
|
// Recreation is the only way to change container env, and it is also what
|
||||||
|
// makes MANAGED_AUTH_KEYS blank a revoked token out of the snapshot image.
|
||||||
|
let expected_claude_token = claude_token_label(project);
|
||||||
|
let container_claude_token = get_label("triple-c.claude-token-version").unwrap_or_default();
|
||||||
|
if container_claude_token != expected_claude_token {
|
||||||
|
log::info!("Shared Claude authentication token mismatch — recreating container");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Custom environment variables (label-based fingerprint) ──────────
|
// ── Custom environment variables (label-based fingerprint) ──────────
|
||||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
|
|||||||
@@ -1,13 +1,78 @@
|
|||||||
use bollard::container::UploadToContainerOptions;
|
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::{Stream, StreamExt};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
|
|
||||||
|
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||||
|
/// attached — the raw duplex halves, before any policy about what to do with
|
||||||
|
/// them.
|
||||||
|
///
|
||||||
|
/// This is the single place in the codebase that knows how to open an attached
|
||||||
|
/// exec. Both consumers are built on it:
|
||||||
|
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||||
|
/// which pump bytes through mpsc channels and a callback.
|
||||||
|
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||||
|
/// straight between a host TCP socket and these halves.
|
||||||
|
///
|
||||||
|
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||||
|
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||||
|
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||||
|
/// spliced into the proxied byte stream.
|
||||||
|
pub struct AttachedExec {
|
||||||
|
pub exec_id: String,
|
||||||
|
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||||
|
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||||
|
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||||
|
/// this app opens.
|
||||||
|
pub async fn create_attached_exec(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
tty: bool,
|
||||||
|
) -> Result<AttachedExec, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdin: Some(true),
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
tty: Some(tty),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
working_dir: Some("/workspace".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let exec_id = exec.id.clone();
|
||||||
|
|
||||||
|
match docker
|
||||||
|
.start_exec(&exec_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?
|
||||||
|
{
|
||||||
|
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||||
|
exec_id,
|
||||||
|
output,
|
||||||
|
input,
|
||||||
|
}),
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ExecSession {
|
pub struct ExecSession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
@@ -80,37 +145,15 @@ impl ExecSessionManager {
|
|||||||
where
|
where
|
||||||
F: Fn(Vec<u8>) + Send + 'static,
|
F: Fn(Vec<u8>) + Send + 'static,
|
||||||
{
|
{
|
||||||
let docker = get_docker()?;
|
let AttachedExec {
|
||||||
|
exec_id,
|
||||||
let exec = docker
|
mut output,
|
||||||
.create_exec(
|
mut input,
|
||||||
container_id,
|
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||||
CreateExecOptions {
|
|
||||||
attach_stdin: Some(true),
|
|
||||||
attach_stdout: Some(true),
|
|
||||||
attach_stderr: Some(true),
|
|
||||||
tty: Some(tty),
|
|
||||||
cmd: Some(cmd),
|
|
||||||
user: Some("claude".to_string()),
|
|
||||||
working_dir: Some("/workspace".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
|
||||||
|
|
||||||
let exec_id = exec.id.clone();
|
|
||||||
|
|
||||||
let result = docker
|
|
||||||
.start_exec(&exec_id, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
|
||||||
|
|
||||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||||
|
|
||||||
match result {
|
|
||||||
StartExecResults::Attached { mut output, mut input } => {
|
|
||||||
// Output reader task
|
// Output reader task
|
||||||
let session_id_clone = session_id.to_string();
|
let session_id_clone = session_id.to_string();
|
||||||
let shutdown_tx_clone = shutdown_tx.clone();
|
let shutdown_tx_clone = shutdown_tx.clone();
|
||||||
@@ -151,11 +194,6 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
StartExecResults::Detached => {
|
|
||||||
return Err("Exec started in detached mode".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let session = ExecSession {
|
let session = ExecSession {
|
||||||
exec_id,
|
exec_id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod auth_bridge;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
mod install_helper;
|
mod install_helper;
|
||||||
@@ -8,6 +9,7 @@ pub mod web_terminal;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use auth_bridge::AuthBridgeManager;
|
||||||
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;
|
||||||
@@ -18,6 +20,7 @@ pub struct AppState {
|
|||||||
pub projects_store: Arc<ProjectsStore>,
|
pub projects_store: Arc<ProjectsStore>,
|
||||||
pub settings_store: Arc<SettingsStore>,
|
pub settings_store: Arc<SettingsStore>,
|
||||||
pub exec_manager: Arc<ExecSessionManager>,
|
pub exec_manager: Arc<ExecSessionManager>,
|
||||||
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||||
|
let auth_bridge = Arc::new(AuthBridgeManager::new());
|
||||||
|
|
||||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||||
let projects_store_setup = projects_store.clone();
|
let projects_store_setup = projects_store.clone();
|
||||||
@@ -53,6 +57,7 @@ pub fn run() {
|
|||||||
projects_store,
|
projects_store,
|
||||||
settings_store,
|
settings_store,
|
||||||
exec_manager,
|
exec_manager,
|
||||||
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
@@ -136,6 +141,8 @@ pub fn run() {
|
|||||||
let _ = docker::stt::stop_stt_container().await;
|
let _ = docker::stt::stop_stt_container().await;
|
||||||
// Close all exec sessions
|
// Close all exec sessions
|
||||||
state.exec_manager.close_all_sessions().await;
|
state.exec_manager.close_all_sessions().await;
|
||||||
|
// Release every host loopback port held by the auth bridge
|
||||||
|
state.auth_bridge.stop_all().await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -155,6 +162,14 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
commands::project_commands::reconcile_project_statuses,
|
||||||
|
// Auth bridge
|
||||||
|
commands::auth_bridge_commands::set_auth_bridge_enabled,
|
||||||
|
commands::auth_bridge_commands::get_auth_bridge_status,
|
||||||
|
// Shared Claude Code auth token
|
||||||
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
|
commands::auth_token_commands::has_claude_token,
|
||||||
|
commands::auth_token_commands::clear_claude_token,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ fn default_full_permissions() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `use_shared_auth_token` defaults to **on**: once the user has run
|
||||||
|
/// `claude setup-token` once, every existing Anthropic-backend project should
|
||||||
|
/// pick the token up without being edited one by one. Projects deliberately
|
||||||
|
/// pinned to their own `claude login` identity opt out.
|
||||||
|
fn default_use_shared_auth_token() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// How much autonomy Claude Code is granted inside the container.
|
/// How much autonomy Claude Code is granted inside the container.
|
||||||
///
|
///
|
||||||
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
||||||
@@ -122,6 +130,23 @@ pub struct Project {
|
|||||||
pub sandbox_mode_enabled: bool,
|
pub sandbox_mode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
pub mission_control_enabled: bool,
|
||||||
|
/// Opt in to the auth bridge: while the container runs, its loopback
|
||||||
|
/// listeners are mirrored onto the host's loopback so browser OAuth
|
||||||
|
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
||||||
|
/// Purely host-side — it deliberately has no container-recreation label,
|
||||||
|
/// because toggling it changes nothing about the container itself.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_bridge_enabled: bool,
|
||||||
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
|
/// [`Backend::Anthropic`] and a token has actually been stored.
|
||||||
|
///
|
||||||
|
/// Defaults to **true** so a single `setup-token` run covers every project;
|
||||||
|
/// turn it off to pin a project to the identity it logged in with inside
|
||||||
|
/// its own container.
|
||||||
|
#[serde(default = "default_use_shared_auth_token")]
|
||||||
|
pub use_shared_auth_token: bool,
|
||||||
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
||||||
/// because it is the value already stored in users' `projects.json`; it is
|
/// because it is the value already stored in users' `projects.json`; it is
|
||||||
/// the fallback in `effective_permission_mode()` so old projects keep
|
/// the fallback in `effective_permission_mode()` so old projects keep
|
||||||
@@ -262,6 +287,8 @@ impl Project {
|
|||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: false,
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ impl ProjectsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Granular setter for the auth bridge opt-in, so toggling it can't clobber
|
||||||
|
/// concurrent edits to the rest of the project record.
|
||||||
|
pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
|
||||||
|
let mut projects = self.lock();
|
||||||
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
p.auth_bridge_enabled = enabled;
|
||||||
|
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
self.save(&projects)?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Project {} not found", project_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
|
||||||
let mut projects = self.lock();
|
let mut projects = self.lock();
|
||||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
|
//! OS keychain access, via the `keyring` crate.
|
||||||
|
//!
|
||||||
|
//! Two kinds of secret live here:
|
||||||
|
//! * **per-project** secrets (git token, AWS keys, …), keyed by project id;
|
||||||
|
//! * the **shared Claude Code OAuth token**, which is global — one
|
||||||
|
//! `claude setup-token` run authenticates every Anthropic-backend project.
|
||||||
|
//!
|
||||||
|
//! Nothing in this module ever logs a secret or folds one into an error string.
|
||||||
|
|
||||||
|
/// Keychain service for the single, global Claude Code OAuth token minted by
|
||||||
|
/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||||
|
const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token";
|
||||||
|
|
||||||
|
/// Keychain service for the token's **rotation id** — a fresh random value
|
||||||
|
/// written every time the token is stored.
|
||||||
|
///
|
||||||
|
/// Container recreation is driven off Docker labels, which anything on the host
|
||||||
|
/// can read with `docker inspect`. The token itself must obviously not go in a
|
||||||
|
/// label, and neither should a bare hash of it: a hash is a verification oracle
|
||||||
|
/// (holding a candidate token, you could confirm it). This id is not derived
|
||||||
|
/// from the token at all — it is unrelated random data that merely *changes*
|
||||||
|
/// whenever the token does, which is exactly (and only) what change detection
|
||||||
|
/// needs.
|
||||||
|
const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version";
|
||||||
|
|
||||||
|
/// Fixed account name used for every triple-c keychain entry.
|
||||||
|
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
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 service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
@@ -43,3 +71,88 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shared Claude Code OAuth token (global, not per project)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a single-value keychain entry. `Ok(None)` when the entry is absent.
|
||||||
|
/// The error text names the entry, never its value.
|
||||||
|
fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.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 {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a keychain entry, treating "wasn't there" as success.
|
||||||
|
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the shared Claude Code OAuth token, replacing any previous one, and
|
||||||
|
/// mint a fresh rotation id so containers holding the old token are flagged for
|
||||||
|
/// recreation. Blank input is rejected rather than silently stored.
|
||||||
|
pub fn store_claude_oauth_token(token: &str) -> Result<(), String> {
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty Claude authentication token.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
entry
|
||||||
|
.set_password(token)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?;
|
||||||
|
|
||||||
|
// Rotation id second: if this fails the token is still usable, and the
|
||||||
|
// stale id only costs one extra container recreation later.
|
||||||
|
let version = uuid::Uuid::new_v4().to_string();
|
||||||
|
let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
|
version_entry
|
||||||
|
.set_password(&version)
|
||||||
|
.map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the shared Claude Code OAuth token, if one has been stored.
|
||||||
|
pub fn get_claude_oauth_token() -> Result<Option<String>, String> {
|
||||||
|
read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rotation id of the currently stored token. Opaque random data — safe to
|
||||||
|
/// put in a Docker label, unlike the token or any hash of it.
|
||||||
|
pub fn get_claude_oauth_token_version() -> Result<Option<String>, String> {
|
||||||
|
read_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shared Claude Code OAuth token is currently stored. A keychain
|
||||||
|
/// failure is reported as "no token" rather than surfacing as an error, so the
|
||||||
|
/// UI degrades to the un-authenticated state instead of breaking.
|
||||||
|
pub fn has_claude_oauth_token() -> bool {
|
||||||
|
matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the shared Claude Code OAuth token and its rotation id. Both are
|
||||||
|
/// attempted even if the first fails, so a partial failure cannot strand the
|
||||||
|
/// token behind a deleted id.
|
||||||
|
pub fn delete_claude_oauth_token() -> Result<(), String> {
|
||||||
|
let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token");
|
||||||
|
let version_result = delete_entry(
|
||||||
|
CLAUDE_TOKEN_VERSION_SERVICE,
|
||||||
|
"the Claude token rotation id",
|
||||||
|
);
|
||||||
|
token_result.and(version_result)
|
||||||
|
}
|
||||||
|
|||||||
+122
-16
@@ -5,13 +5,20 @@ import TopBar from "./components/layout/TopBar";
|
|||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
|
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||||
|
import ToastHost from "./components/ui/ToastHost";
|
||||||
|
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||||
|
import Button from "./components/ui/Button";
|
||||||
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 { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useTerminal } from "./hooks/useTerminal";
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
import { useSTT } from "./hooks/useSTT";
|
import { useSTT } from "./hooks/useSTT";
|
||||||
import { useAppState } from "./store/appState";
|
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||||
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -19,8 +26,16 @@ export default function App() {
|
|||||||
const { loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
sessions: s.sessions,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setProjects: s.setProjects,
|
||||||
|
setSttToggle: s.setSttToggle,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||||
|
|
||||||
@@ -33,6 +48,9 @@ export default function App() {
|
|||||||
setSttToggle(stt.toggle);
|
setSttToggle(stt.toggle);
|
||||||
}, [stt.toggle, setSttToggle]);
|
}, [stt.toggle, setSttToggle]);
|
||||||
|
|
||||||
|
useContainerProgress();
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -69,16 +87,25 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen p-6 gap-4 bg-[var(--bg-primary)]">
|
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex flex-1 min-h-0 gap-4">
|
<div className="flex flex-1 min-h-0 gap-3">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
|
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
|
||||||
{sessions.length === 0 ? (
|
{tabOrder.length === 0 ? (
|
||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
|
{homeProjectIds.map((projectId) => (
|
||||||
|
<ProjectHome
|
||||||
|
key={projectId}
|
||||||
|
projectId={projectId}
|
||||||
|
active={activeTabKey === homeTabKey(projectId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<TerminalView
|
<TerminalView
|
||||||
key={session.id}
|
key={session.id}
|
||||||
@@ -91,6 +118,7 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar stt={stt} />
|
<StatusBar stt={stt} />
|
||||||
|
<ToastHost />
|
||||||
{showInstallDialog && (
|
{showInstallDialog && (
|
||||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
)}
|
)}
|
||||||
@@ -98,18 +126,96 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First run is a checklist, not a paragraph: it reuses state the app already
|
||||||
|
* tracks and ends in a real button.
|
||||||
|
*/
|
||||||
function WelcomeScreen() {
|
function WelcomeScreen() {
|
||||||
|
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
dockerAvailable: s.dockerAvailable,
|
||||||
|
imageExists: s.imageExists,
|
||||||
|
projects: s.projects,
|
||||||
|
openProjectHome: s.openProjectHome,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
|
const steps: {
|
||||||
|
label: string;
|
||||||
|
state: boolean | null;
|
||||||
|
pendingLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: "Docker detected",
|
||||||
|
state: dockerAvailable,
|
||||||
|
pendingLabel: "Checking for Docker…",
|
||||||
|
failLabel: "Docker not available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Container image ready",
|
||||||
|
state: imageExists,
|
||||||
|
pendingLabel: "Checking for the image…",
|
||||||
|
failLabel: "Image not pulled yet — see Settings › Container",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
|
||||||
|
state: projects.length > 0 ? true : false,
|
||||||
|
pendingLabel: "",
|
||||||
|
failLabel: "No projects yet",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<div className="text-center">
|
<div className="w-full max-w-md">
|
||||||
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
|
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
|
||||||
Triple-C
|
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
|
||||||
</h1>
|
Claude Code, sandboxed in a container.
|
||||||
<p className="text-sm mb-4">Claude Code Container</p>
|
|
||||||
<p className="text-xs max-w-md">
|
|
||||||
Add a project from the sidebar, start its container, then open a
|
|
||||||
terminal to begin using Claude Code in a sandboxed environment.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-2 mb-5">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<li
|
||||||
|
key={step.label}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<StatusIndicator
|
||||||
|
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
|
||||||
|
label={
|
||||||
|
step.state === true
|
||||||
|
? step.label
|
||||||
|
: step.state === false
|
||||||
|
? step.failLabel
|
||||||
|
: step.pendingLabel
|
||||||
|
}
|
||||||
|
className="text-[13px]"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
|
||||||
|
{projects.length === 0 ? "Add your first project" : "Add a project"}
|
||||||
|
</Button>
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
|
||||||
|
Open {projects[0].name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-[var(--text-secondary)]">
|
||||||
|
Then start its container and press{" "}
|
||||||
|
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
|
||||||
|
Ctrl+T
|
||||||
|
</kbd>{" "}
|
||||||
|
to open a Claude terminal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
import { useDocker } from "../hooks/useDocker";
|
import { useDocker } from "../hooks/useDocker";
|
||||||
|
import Modal from "./ui/Modal";
|
||||||
|
import Button from "./ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -16,27 +18,11 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
const [phase, setPhase] = useState<Phase>("idle");
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
const [log, setLog] = useState<string[]>([]);
|
const [log, setLog] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [loadOptions]);
|
}, [loadOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && phase !== "installing") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose, phase]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current && phase !== "installing") onClose();
|
|
||||||
},
|
|
||||||
[onClose, phase],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
setPhase("installing");
|
setPhase("installing");
|
||||||
setLog([]);
|
setLog([]);
|
||||||
@@ -70,33 +56,39 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
const installVerb =
|
||||||
|
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Docker not detected"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[34rem]"
|
||||||
|
// Closing mid-install would orphan a privileged installer.
|
||||||
|
dismissible={phase !== "installing"}
|
||||||
|
footer={
|
||||||
|
phase === "idle" ? (
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
|
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||||
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
|
Triple-C needs a Docker-compatible runtime to manage sandboxed project
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
containers. We can install{" "}
|
||||||
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
|
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
|
||||||
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
|
you, or you can follow the official instructions.
|
||||||
for you, or you can follow the official instructions.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{phase === "idle" && (
|
{phase === "idle" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{options.can_auto_install ? (
|
{options.can_auto_install ? (
|
||||||
<button
|
<Button size="md" variant="primary" onClick={handleInstall}>
|
||||||
onClick={handleInstall}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
{installVerb} ({options.auto_install_method})
|
{installVerb} ({options.auto_install_method})
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
One-click install unavailable:{" "}
|
One-click install unavailable:{" "}
|
||||||
<span className="text-[var(--text-primary)]">
|
<span className="text-[var(--text-primary)]">
|
||||||
{options.auto_install_blocker ?? "required tooling missing."}
|
{options.auto_install_blocker ?? "required tooling missing."}
|
||||||
@@ -104,19 +96,13 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button size="md" onClick={() => setShowManual((s) => !s)}>
|
||||||
onClick={() => setShowManual((s) => !s)}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button size="md" onClick={handleOpenDocs}>
|
||||||
onClick={handleOpenDocs}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Open official documentation ↗
|
Open official documentation ↗
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -128,7 +114,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
|
|
||||||
{phase === "done" && (
|
{phase === "done" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="text-sm text-[var(--success)]">Install finished.</div>
|
<div className="text-[13px] text-[var(--success)]">Install finished.</div>
|
||||||
{options.post_install_notes.length > 0 && (
|
{options.post_install_notes.length > 0 && (
|
||||||
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||||
{options.post_install_notes.map((note, i) => (
|
{options.post_install_notes.map((note, i) => (
|
||||||
@@ -137,39 +123,31 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<Button size="md" variant="primary" onClick={handleRecheck}>
|
||||||
onClick={handleRecheck}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Re-check Docker
|
Re-check Docker
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button size="md" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === "error" && (
|
{phase === "error" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="text-sm text-[var(--error)]">Install failed.</div>
|
<div className="text-[13px] text-[var(--error)]">Install failed.</div>
|
||||||
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
|
{error && (
|
||||||
|
<div className="text-xs font-mono text-[var(--error)] break-words">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<Button size="md" onClick={() => setPhase("idle")}>
|
||||||
onClick={() => setPhase("idle")}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button size="md" variant="primary" onClick={handleOpenDocs}>
|
||||||
onClick={handleOpenDocs}
|
|
||||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Open official docs ↗
|
Open official docs ↗
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +157,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||||
Manual install steps
|
Manual install steps
|
||||||
</div>
|
</div>
|
||||||
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
{options.manual_steps.map((step, i) => (
|
{options.manual_steps.map((step, i) => (
|
||||||
<li key={i}>{step}</li>
|
<li key={i}>{step}</li>
|
||||||
))}
|
))}
|
||||||
@@ -188,24 +166,12 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{log.length > 0 && (
|
{log.length > 0 && (
|
||||||
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||||
{log.map((line, i) => (
|
{log.map((line, i) => (
|
||||||
<div key={i}>{line}</div>
|
<div key={i}>{line}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Modal>
|
||||||
{phase === "idle" && (
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { getHelpContent } from "../../lib/tauri-commands";
|
import { getHelpContent } from "../../lib/tauri-commands";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -140,32 +142,16 @@ function renderMarkdown(md: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HelpDialog({ onClose }: Props) {
|
export default function HelpDialog({ onClose }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHelpContent()
|
getHelpContent()
|
||||||
.then(setMarkdown)
|
.then(setMarkdown)
|
||||||
.catch((e) => setError(String(e)));
|
.catch((e) => setError(String(e)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle anchor link clicks to scroll within the dialog
|
// Handle anchor link clicks to scroll within the dialog
|
||||||
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="How to Use Triple-C"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[48rem]"
|
||||||
>
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
|
||||||
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
onClick={handleContentClick}
|
|
||||||
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
|
||||||
>
|
>
|
||||||
|
<div ref={contentRef} onClick={handleContentClick} className="help-content">
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
|
<p className="text-[var(--error)] text-sm">
|
||||||
|
Failed to load help content: {error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{!markdown && !error && (
|
{!markdown && !error && (
|
||||||
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
|
<p className="text-[var(--text-secondary)] text-sm">Loading…</p>
|
||||||
)}
|
)}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
|
import {
|
||||||
|
useAppState,
|
||||||
|
isHomeTab,
|
||||||
|
tabKeyId,
|
||||||
|
terminalTabKey,
|
||||||
|
} from "../../store/appState";
|
||||||
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
sessionId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||||
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
|
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
|
||||||
|
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
|
* terminals (▣).
|
||||||
|
*/
|
||||||
|
export default function MainTabs() {
|
||||||
|
const { sessions, close } = useTerminal();
|
||||||
|
const { projects, update } = useProjects();
|
||||||
|
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
tabOrder: s.tabOrder,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
closeHomeTab: s.closeHomeTab,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const dismiss = () => setMenu(null);
|
||||||
|
window.addEventListener("click", dismiss);
|
||||||
|
window.addEventListener("scroll", dismiss, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", dismiss);
|
||||||
|
window.removeEventListener("scroll", dismiss, true);
|
||||||
|
};
|
||||||
|
}, [menu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingId) {
|
||||||
|
renameInputRef.current?.focus();
|
||||||
|
renameInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [renamingId]);
|
||||||
|
|
||||||
|
if (tabOrder.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
|
No open tabs — select a project to open its home view.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
return project?.renamed_session_names?.[sessionId] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const current =
|
||||||
|
getCustomName(session.projectId, sessionId) ??
|
||||||
|
session.sessionName ??
|
||||||
|
session.projectName;
|
||||||
|
setRenameDraft(current);
|
||||||
|
setRenamingId(sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitRename = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) {
|
||||||
|
setRenamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = renameDraft.trim();
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (trimmed) {
|
||||||
|
map[sessionId] = trimmed;
|
||||||
|
} else {
|
||||||
|
delete map[sessionId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename terminal tab:", err);
|
||||||
|
} finally {
|
||||||
|
setRenamingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCustomName = async (sessionId: string) => {
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
if (!project) return;
|
||||||
|
const map = { ...(project.renamed_session_names ?? {}) };
|
||||||
|
if (!(sessionId in map)) {
|
||||||
|
setMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete map[sessionId];
|
||||||
|
try {
|
||||||
|
await update({ ...project, renamed_session_names: map });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to reset terminal tab name:", err);
|
||||||
|
} finally {
|
||||||
|
setMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (active: boolean) =>
|
||||||
|
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
|
{tabOrder.map((key) => {
|
||||||
|
const active = activeTabKey === key;
|
||||||
|
|
||||||
|
if (isHomeTab(key)) {
|
||||||
|
const projectId = tabKeyId(key);
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setActiveTabKey(key)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={tabClass(active)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||||
|
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeHomeTab(projectId);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${project.name} home tab`}
|
||||||
|
title="Close tab"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = tabKeyId(key);
|
||||||
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
|
const customName = getCustomName(session.projectId, session.id);
|
||||||
|
const baseLabel =
|
||||||
|
(session.sessionName ?? session.projectName) +
|
||||||
|
(session.sessionType === "bash" ? " (bash)" : "");
|
||||||
|
const displayLabel = customName
|
||||||
|
? `${session.projectName}: ${customName}`
|
||||||
|
: baseLabel;
|
||||||
|
const isRenaming = renamingId === session.id;
|
||||||
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTabKey(terminalTabKey(session.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => startRename(session.id)}
|
||||||
|
className={tabClass(active)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameDraft}
|
||||||
|
aria-label="Rename tab"
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onBlur={() => commitRename(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") setRenamingId(null);
|
||||||
|
}}
|
||||||
|
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||||
|
title={`Permission mode: ${badge.text}`}
|
||||||
|
>
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
close(session.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Close ${displayLabel}`}
|
||||||
|
title="Close terminal"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{menu && (() => {
|
||||||
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
|
const hasCustom = session
|
||||||
|
? !!getCustomName(session.projectId, menu.sessionId)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
|
||||||
|
style={{ top: menu.y, left: menu.x, boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => startRename(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Rename tab
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => clearCustomName(menu.sessionId)}
|
||||||
|
>
|
||||||
|
Reset name
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
useAppState.getState().openProjectHome(session.projectId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open project home
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-[var(--border-color)] my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
close(menu.sessionId);
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ describe("Sidebar", () => {
|
|||||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the project list, not a settings form, in the projects view", () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByTestId("project-list")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("content area has min-w-0 to prevent flex overflow", () => {
|
it("content area has min-w-0 to prevent flex overflow", () => {
|
||||||
const { container } = render(<Sidebar />);
|
const { container } = render(<Sidebar />);
|
||||||
const contentArea = container.querySelector(".overflow-y-auto");
|
const contentArea = container.querySelector(".overflow-y-auto");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebarCollapsed}
|
onClick={toggleSidebarCollapsed}
|
||||||
title="Expand sidebar"
|
title="Expand sidebar"
|
||||||
@@ -89,7 +89,7 @@ export default function Sidebar() {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
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-[var(--radius-panel)] overflow-hidden">
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||||
<span>
|
<span>
|
||||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import TerminalTabs from "../terminal/TerminalTabs";
|
import MainTabs from "./MainTabs";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import UpdateDialog from "../settings/UpdateDialog";
|
import UpdateDialog from "../settings/UpdateDialog";
|
||||||
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||||
import HelpDialog from "./HelpDialog";
|
import HelpDialog from "./HelpDialog";
|
||||||
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||||
@@ -48,34 +49,48 @@ export default function TopBar() {
|
|||||||
|
|
||||||
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 items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
|
||||||
<div className="flex-1 overflow-x-auto pl-2">
|
<div className="flex-1 overflow-x-auto pl-1">
|
||||||
<TerminalTabs />
|
<MainTabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center gap-3 px-3 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowUpdateDialog(true)}
|
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"
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--accent-emphasis)] text-white hover:bg-[var(--accent-emphasis-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{imageUpdateInfo && (
|
{imageUpdateInfo && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowImageUpdateDialog(true)}
|
onClick={() => setShowImageUpdateDialog(true)}
|
||||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--warning-emphasis)] text-white hover:opacity-90 transition-colors"
|
||||||
title="A newer container image is available"
|
title="A newer container image is available"
|
||||||
>
|
>
|
||||||
Image Update
|
Image Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<HealthDot
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
state={dockerAvailable}
|
||||||
|
okLabel="Docker"
|
||||||
|
failLabel="Docker unavailable"
|
||||||
|
pendingLabel="Docker — checking"
|
||||||
|
/>
|
||||||
|
<HealthDot
|
||||||
|
state={imageExists}
|
||||||
|
okLabel="Image"
|
||||||
|
failLabel="Image missing"
|
||||||
|
pendingLabel="Image — checking"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowHelpDialog(true)}
|
onClick={() => setShowHelpDialog(true)}
|
||||||
title="Help"
|
title="Help"
|
||||||
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
aria-label="Help"
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
@@ -103,15 +118,29 @@ export default function TopBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
|
/**
|
||||||
return (
|
* `null` (still checking) is visually distinct and pulses; `false` is an
|
||||||
<span className="flex items-center gap-1">
|
* outage and renders red — previously both fell through to the same gray dot.
|
||||||
<span
|
*/
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
function HealthDot({
|
||||||
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
|
state,
|
||||||
}`}
|
okLabel,
|
||||||
/>
|
failLabel,
|
||||||
{label}
|
pendingLabel,
|
||||||
</span>
|
}: {
|
||||||
);
|
state: boolean | null;
|
||||||
|
okLabel: string;
|
||||||
|
failLabel: string;
|
||||||
|
pendingLabel: string;
|
||||||
|
}) {
|
||||||
|
let tone: StatusTone = "unknown";
|
||||||
|
let label = pendingLabel;
|
||||||
|
if (state === true) {
|
||||||
|
tone = "ok";
|
||||||
|
label = okLabel;
|
||||||
|
} else if (state === false) {
|
||||||
|
tone = "error";
|
||||||
|
label = failLabel;
|
||||||
|
}
|
||||||
|
return <StatusIndicator tone={tone} label={label} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useId, useRef, useState } 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";
|
import type { ProjectPath } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { inputClass, monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
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 nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const formId = useId();
|
||||||
|
|
||||||
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 handleBrowse = async (index: number) => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEntry = (
|
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
|
||||||
index: number,
|
|
||||||
field: keyof PathEntry,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const entries = [...pathEntries];
|
const entries = [...pathEntries];
|
||||||
entries[index] = { ...entries[index], [field]: value };
|
entries[index] = { ...entries[index], [field]: value };
|
||||||
setPathEntries(entries);
|
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) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Add Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[30rem]"
|
||||||
|
initialFocusRef={nameInputRef}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||||
|
{loading ? "Adding…" : "Add Project"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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">
|
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
<div>
|
||||||
|
<label
|
||||||
<form onSubmit={handleSubmit}>
|
htmlFor={`${formId}-name`}
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
className="block text-[13px] font-medium mb-1"
|
||||||
Project Name
|
>
|
||||||
|
Project name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id={`${formId}-name`}
|
||||||
ref={nameInputRef}
|
ref={nameInputRef}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="my-project"
|
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)]"
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
<div>
|
||||||
Folders
|
<span className="block text-[13px] font-medium mb-1">Folders</span>
|
||||||
</label>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
{pathEntries.map((entry, i) => (
|
{pathEntries.map((entry, i) => (
|
||||||
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
|
<div
|
||||||
<div className="flex gap-1">
|
key={i}
|
||||||
|
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
value={entry.host_path}
|
value={entry.host_path}
|
||||||
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
|
||||||
placeholder="/path/to/folder"
|
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)]"
|
aria-label={`Folder ${i + 1} host path`}
|
||||||
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button size="md" onClick={() => handleBrowse(i)}>
|
||||||
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
|
Browse
|
||||||
</button>
|
</Button>
|
||||||
{pathEntries.length > 1 && (
|
{pathEntries.length > 1 && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="md"
|
||||||
onClick={() => removeEntry(i)}
|
variant="danger"
|
||||||
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
aria-label={`Remove folder ${i + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setPathEntries(pathEntries.filter((_, j) => j !== i))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
x
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
|
||||||
|
/workspace/
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
value={entry.mount_name}
|
value={entry.mount_name}
|
||||||
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
|
||||||
placeholder="mount-name"
|
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"
|
aria-label={`Folder ${i + 1} mount name`}
|
||||||
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
className="mt-2"
|
||||||
onClick={addEntry}
|
onClick={() =>
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
|
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ Add folder
|
+ Add folder
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="px-2 py-1.5 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/30 rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Adding..." : "Add Project"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
import { SwitchRow, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClaudeCodeSettings | null;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||||
|
tui_mode: null,
|
||||||
|
effort: null,
|
||||||
|
auto_scroll_disabled: false,
|
||||||
|
focus_mode: false,
|
||||||
|
show_thinking_summaries: false,
|
||||||
|
enable_session_recap: false,
|
||||||
|
env_scrub: false,
|
||||||
|
prompt_caching_1h: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||||
|
return (
|
||||||
|
s.tui_mode === null &&
|
||||||
|
s.effort === null &&
|
||||||
|
s.auto_scroll_disabled === false &&
|
||||||
|
s.focus_mode === false &&
|
||||||
|
s.show_thinking_summaries === false &&
|
||||||
|
s.enable_session_recap === false &&
|
||||||
|
s.env_scrub === false &&
|
||||||
|
s.prompt_caching_1h === false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOLEAN_FIELDS: {
|
||||||
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||||
|
{
|
||||||
|
key: "show_thinking_summaries",
|
||||||
|
label: "Thinking summaries",
|
||||||
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enable_session_recap",
|
||||||
|
label: "Session recap",
|
||||||
|
hint: "Provides context when returning to a session.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auto_scroll_disabled",
|
||||||
|
label: "Auto-scroll disabled",
|
||||||
|
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "env_scrub",
|
||||||
|
label: "Env scrub",
|
||||||
|
hint: "Strips credentials from subprocess environments.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "prompt_caching_1h",
|
||||||
|
label: "Prompt caching (1h)",
|
||||||
|
hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ClaudeCodeSettingsEditor({
|
||||||
|
settings,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||||
|
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS });
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const apply = (patch: Partial<ClaudeCodeSettings>) => {
|
||||||
|
const next = { ...local, ...patch };
|
||||||
|
setLocal(next);
|
||||||
|
onSave(isAllDefaults(next) ? null : next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="TUI mode"
|
||||||
|
hint="Enables flicker-free alt-screen rendering."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.tui_mode ?? ""}
|
||||||
|
aria-label="TUI mode"
|
||||||
|
onChange={(e) => apply({ tui_mode: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="fullscreen">Fullscreen</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Effort level"
|
||||||
|
hint="Controls how much reasoning Claude applies."
|
||||||
|
control={
|
||||||
|
<select
|
||||||
|
value={local.effort ?? ""}
|
||||||
|
aria-label="Effort level"
|
||||||
|
onChange={(e) => apply({ effort: e.target.value || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||||
|
<SwitchRow
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label={label}
|
||||||
|
checked={local[key]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: ClaudeCodeSettings | null;
|
settings: ClaudeCodeSettings | null;
|
||||||
@@ -8,184 +10,32 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: ClaudeCodeSettings = {
|
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
|
||||||
tui_mode: null,
|
export default function ClaudeCodeSettingsModal({
|
||||||
effort: null,
|
settings,
|
||||||
auto_scroll_disabled: false,
|
disabled,
|
||||||
focus_mode: false,
|
onSave,
|
||||||
show_thinking_summaries: false,
|
onClose,
|
||||||
enable_session_recap: false,
|
}: Props) {
|
||||||
env_scrub: false,
|
|
||||||
prompt_caching_1h: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
|
||||||
return (
|
return (
|
||||||
s.tui_mode === null &&
|
<Modal
|
||||||
s.effort === null &&
|
title="Claude Code Settings"
|
||||||
s.auto_scroll_disabled === false &&
|
onClose={onClose}
|
||||||
s.focus_mode === false &&
|
widthClassName="w-[34rem]"
|
||||||
s.show_thinking_summaries === false &&
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
s.enable_session_recap === false &&
|
>
|
||||||
s.env_scrub === false &&
|
<ClaudeCodeSettingsEditor
|
||||||
s.prompt_caching_1h === false
|
settings={settings}
|
||||||
);
|
disabled={disabled}
|
||||||
}
|
disabledReason="Container must be stopped to change Claude Code settings."
|
||||||
|
onSave={async (next) => {
|
||||||
export default function ClaudeCodeSettingsModal({ settings, disabled, onSave, onClose }: Props) {
|
|
||||||
const [local, setLocal] = useState<ClaudeCodeSettings>(settings ?? { ...DEFAULTS });
|
|
||||||
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 update = async (patch: Partial<ClaudeCodeSettings>) => {
|
|
||||||
const next = { ...local, ...patch };
|
|
||||||
setLocal(next);
|
|
||||||
try {
|
try {
|
||||||
await onSave(isAllDefaults(next) ? null : next);
|
await onSave(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save Claude Code settings:", err);
|
console.error("Failed to save Claude Code settings:", err);
|
||||||
}
|
}
|
||||||
};
|
}}
|
||||||
|
/>
|
||||||
const toggleButton = (label: string, description: string, value: boolean, onChange: (v: boolean) => void) => (
|
</Modal>
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">{label}</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">{description}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 shrink-0 ${
|
|
||||||
value
|
|
||||||
? "bg-[var(--success)] text-white"
|
|
||||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value ? "ON" : "OFF"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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-[32rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</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 Claude Code settings.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
{/* TUI Mode */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">TUI Mode</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">Enables flicker-free alt-screen rendering</div>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={local.tui_mode ?? ""}
|
|
||||||
onChange={(e) => update({ tui_mode: e.target.value || null })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 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 shrink-0"
|
|
||||||
>
|
|
||||||
<option value="">Default</option>
|
|
||||||
<option value="fullscreen">Fullscreen</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Effort Level */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium text-[var(--text-primary)]">Effort Level</div>
|
|
||||||
<div className="text-xs text-[var(--text-secondary)]">Controls how much reasoning Claude applies</div>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={local.effort ?? ""}
|
|
||||||
onChange={(e) => update({ effort: e.target.value || null })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 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 shrink-0"
|
|
||||||
>
|
|
||||||
<option value="">Default</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Boolean toggles */}
|
|
||||||
{toggleButton(
|
|
||||||
"Focus Mode",
|
|
||||||
"Collapses tool output to one-line summaries",
|
|
||||||
local.focus_mode,
|
|
||||||
(v) => update({ focus_mode: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Thinking Summaries",
|
|
||||||
"Shows thinking process as summaries",
|
|
||||||
local.show_thinking_summaries,
|
|
||||||
(v) => update({ show_thinking_summaries: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Session Recap",
|
|
||||||
"Provides context when returning to a session",
|
|
||||||
local.enable_session_recap,
|
|
||||||
(v) => update({ enable_session_recap: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Auto-Scroll Disabled",
|
|
||||||
"Disables auto-scroll when in fullscreen TUI mode",
|
|
||||||
local.auto_scroll_disabled,
|
|
||||||
(v) => update({ auto_scroll_disabled: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Env Scrub",
|
|
||||||
"Strips credentials from subprocess environments for security",
|
|
||||||
local.env_scrub,
|
|
||||||
(v) => update({ env_scrub: v }),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toggleButton(
|
|
||||||
"Prompt Caching (1h)",
|
|
||||||
"Enables 1-hour prompt cache TTL instead of 5 minutes",
|
|
||||||
local.prompt_caching_1h,
|
|
||||||
(v) => update({ prompt_caching_1h: v }),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (instructions: string) => Promise<unknown>;
|
||||||
|
rows?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsEditor({
|
||||||
|
instructions: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
rows = 10,
|
||||||
|
autoFocus = false,
|
||||||
|
}: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInstructions(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
onBlur={() => onSave(instructions)}
|
||||||
|
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||||
|
aria-label="Claude instructions"
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] resize-y font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import ClaudeInstructionsEditor from "./ClaudeInstructionsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
@@ -7,74 +9,35 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
|
||||||
const [instructions, setInstructions] = useState(initial);
|
export default function ClaudeInstructionsModal({
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
instructions,
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
disabled,
|
||||||
|
onSave,
|
||||||
useEffect(() => {
|
onClose,
|
||||||
textareaRef.current?.focus();
|
}: Props) {
|
||||||
}, []);
|
return (
|
||||||
|
<Modal
|
||||||
useEffect(() => {
|
title="Claude Instructions"
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
description="Written to ~/.claude/CLAUDE.md inside containers."
|
||||||
if (e.key === "Escape") onClose();
|
onClose={onClose}
|
||||||
};
|
widthClassName="w-[40rem]"
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
>
|
||||||
}, [onClose]);
|
<ClaudeInstructionsEditor
|
||||||
|
instructions={instructions}
|
||||||
const handleOverlayClick = useCallback(
|
disabled={disabled}
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
disabledReason="Container must be stopped to change Claude instructions."
|
||||||
if (e.target === overlayRef.current) onClose();
|
rows={14}
|
||||||
},
|
autoFocus
|
||||||
[onClose],
|
onSave={async (value) => {
|
||||||
);
|
try {
|
||||||
|
await onSave(value);
|
||||||
const handleBlur = async () => {
|
} catch (err) {
|
||||||
try { await onSave(instructions); } catch (err) {
|
|
||||||
console.error("Failed to update Claude instructions:", 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"
|
|
||||||
/>
|
/>
|
||||||
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -7,49 +8,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onCancel();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current) onCancel();
|
|
||||||
},
|
|
||||||
[onCancel],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Remove Project"
|
||||||
onClick={handleOverlayClick}
|
onClose={onCancel}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[26rem]"
|
||||||
>
|
footer={
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
<>
|
||||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
<Button size="md" variant="ghost" onClick={onCancel}>
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
|
||||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="md"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Are you sure you want to remove{" "}
|
||||||
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||||
|
delete the container, config volume, and stored credentials.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectName: string;
|
|
||||||
operation: "starting" | "stopping" | "resetting";
|
|
||||||
progressMsg: string | null;
|
|
||||||
error: string | null;
|
|
||||||
completed: boolean;
|
|
||||||
onForceStop: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationLabels: Record<string, string> = {
|
|
||||||
starting: "Starting",
|
|
||||||
stopping: "Stopping",
|
|
||||||
resetting: "Resetting",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContainerProgressModal({
|
|
||||||
projectName,
|
|
||||||
operation,
|
|
||||||
progressMsg,
|
|
||||||
error,
|
|
||||||
completed,
|
|
||||||
onForceStop,
|
|
||||||
onClose,
|
|
||||||
}: Props) {
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-close on success after 800ms
|
|
||||||
useEffect(() => {
|
|
||||||
if (completed && !error) {
|
|
||||||
const timer = setTimeout(onClose, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
// Escape to close (only when completed or error)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && (completed || error)) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [completed, error, onClose]);
|
|
||||||
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === overlayRef.current && (completed || error)) onClose();
|
|
||||||
},
|
|
||||||
[completed, error, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const inProgress = !completed && !error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overlayRef}
|
|
||||||
onClick={handleOverlayClick}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">
|
|
||||||
{operationLabels[operation]} “{projectName}”
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Spinner / checkmark / error icon */}
|
|
||||||
<div className="flex justify-center mb-3">
|
|
||||||
{error ? (
|
|
||||||
<span className="text-3xl text-[var(--error)]">✕</span>
|
|
||||||
) : completed ? (
|
|
||||||
<span className="text-3xl text-[var(--success)]">✓</span>
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress message */}
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
|
|
||||||
{error
|
|
||||||
? <span className="text-[var(--error)]">{error}</span>
|
|
||||||
: completed
|
|
||||||
? "Done!"
|
|
||||||
: progressMsg ?? `${operationLabels[operation]}...`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex justify-center gap-2">
|
|
||||||
{inProgress && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
|
|
||||||
>
|
|
||||||
Force Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(completed || error) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
||||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Env-var table. Used inline in Project Home → Config and in global Settings. */
|
||||||
|
export default function EnvVarsEditor({
|
||||||
|
envVars: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVars(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||||
|
const updated = [...vars];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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={() => onSave(vars)}
|
||||||
|
placeholder="KEY"
|
||||||
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-2/5 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
|
onBlur={() => onSave(vars)}
|
||||||
|
placeholder="value"
|
||||||
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove environment variable ${ev.key || i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = vars.filter((_, j) => j !== i);
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...vars, { key: "", value: "" }];
|
||||||
|
setVars(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import EnvVarsEditor from "./EnvVarsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
envVars: EnvVar[];
|
envVars: EnvVar[];
|
||||||
@@ -8,117 +10,27 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
|
||||||
const [vars, setVars] = useState<EnvVar[]>(initial);
|
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
return (
|
||||||
|
<Modal
|
||||||
useEffect(() => {
|
title="Environment Variables"
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
onClose={onClose}
|
||||||
if (e.key === "Escape") onClose();
|
widthClassName="w-[36rem]"
|
||||||
};
|
footer={<Button onClick={onClose}>Close</Button>}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
>
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
<EnvVarsEditor
|
||||||
}, [onClose]);
|
envVars={envVars}
|
||||||
|
disabled={disabled}
|
||||||
const handleOverlayClick = useCallback(
|
disabledReason="Container must be stopped to change environment variables."
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onSave={async (vars) => {
|
||||||
if (e.target === overlayRef.current) onClose();
|
try {
|
||||||
},
|
await onSave(vars);
|
||||||
[onClose],
|
} catch (err) {
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
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
|
</Modal>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { useFileManager } from "../../hooks/useFileManager";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: string;
|
|
||||||
projectName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
|
||||||
const {
|
|
||||||
currentPath,
|
|
||||||
entries,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
navigate,
|
|
||||||
goUp,
|
|
||||||
refresh,
|
|
||||||
downloadFile,
|
|
||||||
uploadFile,
|
|
||||||
} = useFileManager(projectId);
|
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Load initial directory
|
|
||||||
useEffect(() => {
|
|
||||||
navigate("/workspace");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build breadcrumbs from current path
|
|
||||||
const breadcrumbs = currentPath === "/"
|
|
||||||
? [{ label: "/", path: "/" }]
|
|
||||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
|
||||||
if (i === 0) {
|
|
||||||
acc.push({ label: "/", path: "/" });
|
|
||||||
} else if (part) {
|
|
||||||
const parentPath = acc[acc.length - 1].path;
|
|
||||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
|
||||||
acc.push({ label: part, path: fullPath });
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)] flex-shrink-0">
|
|
||||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Path bar */}
|
|
||||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
|
||||||
{breadcrumbs.map((crumb, i) => (
|
|
||||||
<span key={crumb.path} className="flex items-center gap-1">
|
|
||||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(crumb.path)}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{crumb.label}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
|
||||||
title="Refresh"
|
|
||||||
>
|
|
||||||
↻
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
|
||||||
{error && (
|
|
||||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && entries.length === 0 ? (
|
|
||||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<tbody>
|
|
||||||
{/* Go up entry */}
|
|
||||||
{currentPath !== "/" && (
|
|
||||||
<tr
|
|
||||||
onClick={() => goUp()}
|
|
||||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<tr
|
|
||||||
key={entry.name}
|
|
||||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
|
||||||
className={`${
|
|
||||||
entry.is_directory ? "cursor-pointer" : ""
|
|
||||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-1.5">
|
|
||||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
|
||||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
|
||||||
{!entry.is_directory && formatSize(entry.size)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
|
||||||
{entry.modified}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
{!entry.is_directory && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadFile(entry);
|
|
||||||
}}
|
|
||||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{entries.length === 0 && !loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
|
||||||
Empty directory
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)] flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={uploadFile}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
Upload file
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import PermissionModeControl, {
|
||||||
|
effectivePermissionMode,
|
||||||
|
permissionModePatch,
|
||||||
|
} from "./PermissionModeControl";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "p1",
|
||||||
|
name: "api-server",
|
||||||
|
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "running",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("effectivePermissionMode", () => {
|
||||||
|
it("falls back to the legacy boolean when permission_mode is null", () => {
|
||||||
|
expect(effectivePermissionMode(baseProject)).toBe("default");
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({ ...baseProject, full_permissions: true }),
|
||||||
|
).toBe("bypass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers permission_mode when it is set", () => {
|
||||||
|
expect(
|
||||||
|
effectivePermissionMode({
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "plan",
|
||||||
|
full_permissions: true,
|
||||||
|
}),
|
||||||
|
).toBe("plan");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissionModePatch", () => {
|
||||||
|
it("keeps the legacy full_permissions flag in sync", () => {
|
||||||
|
expect(permissionModePatch("bypass")).toEqual({
|
||||||
|
permission_mode: "bypass",
|
||||||
|
full_permissions: true,
|
||||||
|
});
|
||||||
|
expect(permissionModePatch("acceptEdits")).toEqual({
|
||||||
|
permission_mode: "acceptEdits",
|
||||||
|
full_permissions: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PermissionModeControl", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all four modes as a radio group with the effective one checked", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
|
||||||
|
expect(group).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
||||||
|
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the picked mode", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Accept Edits" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves selection with the arrow keys", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
|
||||||
|
key: "ArrowRight",
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith("acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sandbox state beside the control", () => {
|
||||||
|
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
|
||||||
|
expect(screen.getByTestId("sandbox-state")).toHaveTextContent(
|
||||||
|
/Sandbox\s*ON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not paint Bypass as dangerous while the sandbox contains it", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{ ...baseProject, permission_mode: "bypass" }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--accent-emphasis");
|
||||||
|
expect(bypass.className).not.toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/contained by the sandbox/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses caution colour only when Bypass runs with the sandbox off", () => {
|
||||||
|
render(
|
||||||
|
<PermissionModeControl
|
||||||
|
project={{
|
||||||
|
...baseProject,
|
||||||
|
permission_mode: "bypass",
|
||||||
|
sandbox_mode_enabled: false,
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bypass = screen.getByRole("radio", { name: "Bypass" });
|
||||||
|
expect(bypass.className).toContain("--warning-emphasis");
|
||||||
|
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
|
||||||
|
/Caution/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { PermissionMode, Project } from "../../lib/types";
|
||||||
|
import SegmentedControl, { type Segment } from "../ui/SegmentedControl";
|
||||||
|
|
||||||
|
export const PERMISSION_MODES: Segment<PermissionMode>[] = [
|
||||||
|
{ value: "plan", label: "Plan", hint: "Claude proposes a plan and makes no changes." },
|
||||||
|
{ value: "default", label: "Default", hint: "Claude asks before each tool call." },
|
||||||
|
{
|
||||||
|
value: "acceptEdits",
|
||||||
|
label: "Accept Edits",
|
||||||
|
hint: "File edits are auto-approved; other tools still prompt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bypass",
|
||||||
|
label: "Bypass",
|
||||||
|
hint: "Every tool call is auto-approved (--dangerously-skip-permissions).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `permission_mode` is nullable for projects saved before it existed; fall back
|
||||||
|
* to the legacy boolean.
|
||||||
|
*/
|
||||||
|
export function effectivePermissionMode(project: Project): PermissionMode {
|
||||||
|
return project.permission_mode ?? (project.full_permissions ? "bypass" : "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch to apply when the user picks a mode. `full_permissions` is kept in
|
||||||
|
* sync so anything still reading the legacy field cannot drift.
|
||||||
|
*/
|
||||||
|
export function permissionModePatch(mode: PermissionMode): Partial<Project> {
|
||||||
|
return { permission_mode: mode, full_permissions: mode === "bypass" };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
onChange: (mode: PermissionMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Explanation of why the control is disabled, shown beneath it. */
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hero control. Per §B3.3: Bypass is only painted as caution when the
|
||||||
|
* sandbox is OFF — with the sandbox ON, bypassing prompts is contained.
|
||||||
|
*/
|
||||||
|
export default function PermissionModeControl({
|
||||||
|
project,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
disabledReason,
|
||||||
|
}: Props) {
|
||||||
|
const mode = effectivePermissionMode(project);
|
||||||
|
const sandboxOn = project.sandbox_mode_enabled;
|
||||||
|
const uncontainedBypass = mode === "bypass" && !sandboxOn;
|
||||||
|
|
||||||
|
const segments = PERMISSION_MODES.map((segment) =>
|
||||||
|
segment.value === "bypass" ? { ...segment, caution: !sandboxOn } : segment,
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = PERMISSION_MODES.find((s) => s.value === mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Permission mode
|
||||||
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Permission mode"
|
||||||
|
segments={segments}
|
||||||
|
value={mode}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs text-[var(--text-secondary)]"
|
||||||
|
data-testid="sandbox-state"
|
||||||
|
>
|
||||||
|
Sandbox{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
sandboxOn ? "text-[var(--success)] font-semibold" : "text-[var(--warning)] font-semibold"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sandboxOn ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
{sandboxOn ? " — bubblewrap isolation" : " — no filesystem/network isolation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-xs leading-snug ${
|
||||||
|
uncontainedBypass ? "text-[var(--warning)]" : "text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
data-testid="permission-mode-hint"
|
||||||
|
>
|
||||||
|
{uncontainedBypass
|
||||||
|
? "Caution: every tool call is auto-approved and the sandbox is off, so nothing contains what Claude runs."
|
||||||
|
: mode === "bypass"
|
||||||
|
? "Every tool call is auto-approved — contained by the sandbox."
|
||||||
|
: (active?.hint ?? "")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.status === "running" && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">
|
||||||
|
Applies to terminals opened from now on.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PortMapping } from "../../lib/types";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { monoInputClass, selectClass } from "../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portMappings: PortMapping[];
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
onSave: (mappings: PortMapping[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortMappingsEditor({
|
||||||
|
portMappings: initial,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMappings(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const updatePort = (
|
||||||
|
index: number,
|
||||||
|
field: "host_port" | "container_port",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
|
||||||
|
{disabledReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||||
|
<span className="w-[28%]">Host port</span>
|
||||||
|
<span className="w-[28%]">Container port</span>
|
||||||
|
<span className="w-[22%]">Protocol</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.map((pm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.host_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Host port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.container_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||||
|
onBlur={() => onSave(mappings)}
|
||||||
|
placeholder="8080"
|
||||||
|
aria-label={`Container port ${i + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[28%] ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pm.protocol}
|
||||||
|
aria-label={`Protocol ${i + 1}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[i] = { ...updated[i], protocol: e.target.value };
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-[22%] ${selectClass}`}
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove port mapping ${i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = mappings.filter((_, j) => j !== i);
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [
|
||||||
|
...mappings,
|
||||||
|
{ host_port: 0, container_port: 0, protocol: "tcp" },
|
||||||
|
];
|
||||||
|
setMappings(updated);
|
||||||
|
onSave(updated);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add port mapping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { PortMapping } from "../../lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
portMappings: PortMapping[];
|
|
||||||
disabled: boolean;
|
|
||||||
onSave: (mappings: PortMapping[]) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
|
||||||
const [mappings, setMappings] = useState<PortMapping[]>(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 updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProtocol = (index: number, value: string) => {
|
|
||||||
const updated = [...mappings];
|
|
||||||
updated[index] = { ...updated[index], protocol: value };
|
|
||||||
setMappings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMapping = async (index: number) => {
|
|
||||||
const updated = mappings.filter((_, i) => i !== index);
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to remove port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMapping = async () => {
|
|
||||||
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
|
||||||
setMappings(updated);
|
|
||||||
try { await onSave(updated); } catch (err) {
|
|
||||||
console.error("Failed to add port mapping:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = async () => {
|
|
||||||
try { await onSave(mappings); } catch (err) {
|
|
||||||
console.error("Failed to update port mappings:", 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-2">Port Mappings</h2>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
|
||||||
Map host ports to container ports. Services can be started after the container is running.
|
|
||||||
</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 port mappings.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{mappings.length === 0 && (
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
|
||||||
)}
|
|
||||||
{mappings.length > 0 && (
|
|
||||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
|
||||||
<span className="w-[30%]">Host Port</span>
|
|
||||||
<span className="w-[30%]">Container Port</span>
|
|
||||||
<span className="w-[25%]">Protocol</span>
|
|
||||||
<span className="w-[15%]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mappings.map((pm, i) => (
|
|
||||||
<div key={i} className="flex gap-2 items-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.host_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] 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
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value={pm.container_port || ""}
|
|
||||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="8080"
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[30%] 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"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={pm.protocol}
|
|
||||||
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[25%] 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"
|
|
||||||
>
|
|
||||||
<option value="tcp">TCP</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => removeMapping(i)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<button
|
|
||||||
onClick={addMapping}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add port mapping
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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",
|
|
||||||
backend: "anthropic",
|
|
||||||
bedrock_config: null,
|
|
||||||
allow_docker_access: false,
|
|
||||||
ssh_key_path: null,
|
|
||||||
git_token: null,
|
|
||||||
git_user_name: null,
|
|
||||||
git_user_email: null,
|
|
||||||
custom_env_vars: [],
|
|
||||||
port_mappings: [],
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,32 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import ProjectCard from "./ProjectCard";
|
import ProjectRow from "./ProjectRow";
|
||||||
import AddProjectDialog from "./AddProjectDialog";
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
<div className="flex items-center justify-between px-1 py-1 mb-1.5">
|
||||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Projects
|
Projects
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button onClick={() => setShowAdd(true)} aria-label="Add project">
|
||||||
onClick={() => setShowAdd(true)}
|
+ Add
|
||||||
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
|
</Button>
|
||||||
title="Add project"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<p className="px-2 text-sm text-[var(--text-secondary)]">
|
<p className="px-1 text-xs text-[var(--text-secondary)]">
|
||||||
No projects yet. Click + to add one.
|
No projects yet — use “+ Add” to create one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectRow key={project.id} project={project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import ProjectRow from "./ProjectRow";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
|
const mockStart = vi.fn();
|
||||||
|
const mockStop = vi.fn();
|
||||||
|
const mockOpenClaudeTerminal = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjectActions", () => ({
|
||||||
|
useProjectActions: () => ({
|
||||||
|
busy: false,
|
||||||
|
backingUp: false,
|
||||||
|
handleStart: mockStart,
|
||||||
|
handleStop: mockStop,
|
||||||
|
handleReset: vi.fn(),
|
||||||
|
handleBackup: vi.fn(),
|
||||||
|
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||||
|
openShell: vi.fn(),
|
||||||
|
openTerminalWithCommand: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockOpenProjectHome = vi.fn();
|
||||||
|
let storeState: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
vi.mock("../../store/appState", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../store/appState")>(
|
||||||
|
"../../store/appState",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseProject: Project = {
|
||||||
|
id: "test-1",
|
||||||
|
name: "Test Project",
|
||||||
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
|
container_id: null,
|
||||||
|
status: "stopped",
|
||||||
|
backend: "anthropic",
|
||||||
|
bedrock_config: null,
|
||||||
|
ollama_config: null,
|
||||||
|
openai_compatible_config: null,
|
||||||
|
allow_docker_access: false,
|
||||||
|
sandbox_mode_enabled: true,
|
||||||
|
mission_control_enabled: false,
|
||||||
|
full_permissions: false,
|
||||||
|
permission_mode: null,
|
||||||
|
ssh_key_path: null,
|
||||||
|
git_token: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
custom_env_vars: [],
|
||||||
|
port_mappings: [],
|
||||||
|
claude_instructions: null,
|
||||||
|
claude_code_settings: null,
|
||||||
|
renamed_session_names: {},
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
storeState = {
|
||||||
|
activeTabKey: null,
|
||||||
|
selectedProjectId: null,
|
||||||
|
openProjectHome: mockOpenProjectHome,
|
||||||
|
containerProgress: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectRow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders project name and mount path", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("row root has min-w-0 and overflow-hidden to contain content", () => {
|
||||||
|
const { container } = render(<ProjectRow project={baseProject} />);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(row!.className).toContain("min-w-0");
|
||||||
|
expect(row!.className).toContain("overflow-hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("communicates status with a word, not colour alone", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
|
||||||
|
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting the row opens that project's home tab instead of expanding in place", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByText("Test Project"));
|
||||||
|
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
|
||||||
|
// No config form is rendered in the sidebar any more.
|
||||||
|
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers start when stopped and stop when running", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
|
||||||
|
expect(mockStart).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import type { Project } from "../../lib/types";
|
||||||
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar rows are select-only: name, paths, status, and hover controls.
|
||||||
|
* Clicking a row opens (or focuses) that project's Project Home tab — the
|
||||||
|
* settings form no longer lives in a 280px accordion.
|
||||||
|
*/
|
||||||
|
export default function ProjectRow({ project }: Props) {
|
||||||
|
const { activeTabKey, selectedProjectId, openProjectHome, progress } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
selectedProjectId: s.selectedProjectId,
|
||||||
|
openProjectHome: s.openProjectHome,
|
||||||
|
progress: s.containerProgress[project.id],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const { busy, handleStart, handleStop, openClaudeTerminal } =
|
||||||
|
useProjectActions(project);
|
||||||
|
|
||||||
|
const isSelected =
|
||||||
|
activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id;
|
||||||
|
const isRunning = project.status === "running";
|
||||||
|
const isTransitioning =
|
||||||
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--bg-tertiary)]"
|
||||||
|
: "hover:bg-[var(--bg-tertiary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProjectHome(project.id)}
|
||||||
|
aria-current={isSelected ? "true" : undefined}
|
||||||
|
className="w-full text-left min-w-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||||
|
<span className="text-[13px] font-medium truncate flex-1 text-[var(--text-primary)]">
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
{/* Space reserved for the hover controls so the name never jumps. */}
|
||||||
|
<span className="w-[3.75rem] flex-shrink-0" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 ml-4 space-y-0.5 min-w-0">
|
||||||
|
{project.paths.map((pp, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-xs text-[var(--text-secondary)] truncate font-mono"
|
||||||
|
>
|
||||||
|
/workspace/{pp.mount_name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-xs">
|
||||||
|
{isTransitioning ? (
|
||||||
|
<span className="text-[var(--warning)] truncate block">
|
||||||
|
{progress ?? `${project.status}…`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ProjectStatusIndicator
|
||||||
|
status={project.status}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hover / focus-within controls */}
|
||||||
|
<div className="absolute top-1.5 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
// While a container is mid-transition this stays live so it can act
|
||||||
|
// as the force-stop that the old progress modal used to offer.
|
||||||
|
onClick={() => (isRunning || isTransitioning ? handleStop() : handleStart())}
|
||||||
|
title={
|
||||||
|
isTransitioning
|
||||||
|
? `Force stop ${project.name}`
|
||||||
|
: isRunning
|
||||||
|
? `Stop ${project.name}`
|
||||||
|
: `Start ${project.name}`
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
isTransitioning
|
||||||
|
? `Force stop ${project.name}`
|
||||||
|
: isRunning
|
||||||
|
? `Stop ${project.name}`
|
||||||
|
: `Start ${project.name}`
|
||||||
|
}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
|
>
|
||||||
|
{isRunning || isTransitioning ? (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 5.5v13l11-6.5z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isRunning}
|
||||||
|
onClick={() => openClaudeTerminal()}
|
||||||
|
title={`Open a Claude terminal for ${project.name}`}
|
||||||
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
<polyline points="7 9 10 12 7 15" />
|
||||||
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Project, ScheduledTask, SchedulerNotification } from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
clearSchedulerNotifications,
|
||||||
|
getScheduledTaskLog,
|
||||||
|
getSchedulerNotifications,
|
||||||
|
listScheduledTasks,
|
||||||
|
removeScheduledTask,
|
||||||
|
runScheduledTaskNow,
|
||||||
|
setScheduledTaskEnabled,
|
||||||
|
} from "../../../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import Toggle from "../../ui/Toggle";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
import { formatAge } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI for `triple-c-scheduler`, which ships in every container and until now
|
||||||
|
* had no interface beyond a CLAUDE.md paragraph.
|
||||||
|
*/
|
||||||
|
export default function AutomationTab({ project }: Props) {
|
||||||
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
|
const [notifications, setNotifications] = useState<SchedulerNotification[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||||
|
const [log, setLog] = useState<{ task: ScheduledTask; text: string } | null>(null);
|
||||||
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!running) {
|
||||||
|
setTasks([]);
|
||||||
|
setNotifications([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
listScheduledTasks(project.id).catch(() => [] as ScheduledTask[]),
|
||||||
|
getSchedulerNotifications(project.id).catch(
|
||||||
|
() => [] as SchedulerNotification[],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([t, n]) => {
|
||||||
|
setTasks(t);
|
||||||
|
setNotifications(n);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [project.id, running]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||||
|
setBusyTaskId(taskId);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: `${label} failed`, detail: String(e) });
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLog = async (task: ScheduledTask) => {
|
||||||
|
setBusyTaskId(task.id);
|
||||||
|
try {
|
||||||
|
const text = await getScheduledTaskLog(project.id, task.id, 200);
|
||||||
|
setLog({ task, text });
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not read the log for “${task.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removing = tasks.find((t) => t.id === confirmRemoveId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 max-w-4xl">
|
||||||
|
{/* Notifications */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<section className="border border-[var(--accent)]/40 bg-[var(--accent-muted)] rounded-[var(--radius-panel)]">
|
||||||
|
<header className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--accent)]">
|
||||||
|
{notifications.length} notification
|
||||||
|
{notifications.length === 1 ? "" : "s"}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await clearSchedulerNotifications(project.id);
|
||||||
|
setNotifications([]);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not clear notifications",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<ul className="divide-y divide-[var(--border-color)]">
|
||||||
|
{notifications.map((n, i) => (
|
||||||
|
<li key={`${n.task_id}-${i}`} className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium text-[var(--text-primary)]">
|
||||||
|
{n.task_name ?? n.task_id}
|
||||||
|
</span>
|
||||||
|
{n.status && (
|
||||||
|
<StatusIndicator
|
||||||
|
tone={n.status.toLowerCase() === "success" ? "ok" : "error"}
|
||||||
|
label={n.status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--text-secondary)] ml-auto">
|
||||||
|
{formatAge(n.created_at) ?? n.time ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||||
|
{n.summary ?? n.body}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Recurring Claude Code runs managed by{" "}
|
||||||
|
<code className="font-mono text-[var(--text-primary)]">
|
||||||
|
triple-c-scheduler
|
||||||
|
</code>{" "}
|
||||||
|
inside the container.
|
||||||
|
</p>
|
||||||
|
<Button onClick={load} disabled={!running || loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to list its scheduled tasks.
|
||||||
|
</p>
|
||||||
|
) : tasks.length === 0 && !loading ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
No scheduled tasks. Ask Claude to add one with{" "}
|
||||||
|
<code className="font-mono">triple-c-scheduler add</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||||
|
{task.task_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||||
|
{task.at ?? task.schedule}
|
||||||
|
{task.last_run ? ` · last run ${formatAge(task.last_run) ?? task.last_run}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
label={`${task.name} enabled`}
|
||||||
|
checked={task.enabled}
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onChange={(v) =>
|
||||||
|
withTask(task.id, "Toggle task", () =>
|
||||||
|
setScheduledTaskEnabled(project.id, task.id, v),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onClick={() =>
|
||||||
|
withTask(task.id, "Run now", () =>
|
||||||
|
runScheduledTaskNow(project.id, task.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Run now
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
|
||||||
|
Log
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={busyTaskId === task.id}
|
||||||
|
onClick={() => setConfirmRemoveId(task.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{log && (
|
||||||
|
<Modal
|
||||||
|
title={`Log — ${log.task.name}`}
|
||||||
|
onClose={() => setLog(null)}
|
||||||
|
widthClassName="w-[46rem]"
|
||||||
|
footer={<Button onClick={() => setLog(null)}>Close</Button>}
|
||||||
|
>
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{log.text.trim() || "(empty log)"}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{removing && (
|
||||||
|
<Modal
|
||||||
|
title="Remove scheduled task"
|
||||||
|
onClose={() => setConfirmRemoveId(null)}
|
||||||
|
widthClassName="w-[26rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRemoveId(null);
|
||||||
|
withTask(removing.id, "Remove task", () =>
|
||||||
|
removeScheduledTask(project.id, removing.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Remove <strong className="text-[var(--text-primary)]">{removing.name}</strong>{" "}
|
||||||
|
from this container’s scheduler?
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
CapabilityGroup,
|
||||||
|
ContainerCapabilities,
|
||||||
|
Project,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
import { listContainerCapabilities } from "../../../lib/tauri-commands";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only inventory of what Claude Code can do inside this container.
|
||||||
|
* Triple-C surfaces counts and launches the real editors in the terminal —
|
||||||
|
* it does not rebuild `/agents`, `/hooks`, or `/plugins` as forms.
|
||||||
|
*/
|
||||||
|
const GROUPS: { key: keyof ContainerCapabilities; label: string }[] = [
|
||||||
|
{ key: "skills", label: "Skills" },
|
||||||
|
{ key: "agents", label: "Agents" },
|
||||||
|
{ key: "commands", label: "Commands" },
|
||||||
|
{ key: "hooks", label: "Hooks" },
|
||||||
|
{ key: "plugins", label: "Plugins" },
|
||||||
|
{ key: "mcp_servers", label: "MCP servers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SLASH_HINT: Partial<Record<keyof ContainerCapabilities, string>> = {
|
||||||
|
agents: "/agents",
|
||||||
|
hooks: "/hooks",
|
||||||
|
plugins: "/plugins",
|
||||||
|
mcp_servers: "/mcp",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
onManageInTerminal: (command: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CapabilityTiles({ project, onManageInTerminal }: Props) {
|
||||||
|
const [capabilities, setCapabilities] = useState<ContainerCapabilities | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState<keyof ContainerCapabilities | null>(null);
|
||||||
|
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
setCapabilities(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
listContainerCapabilities(project.id)
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCapabilities(c);
|
||||||
|
})
|
||||||
|
// Introspection degrades to "nothing found" when the container is
|
||||||
|
// unreachable — that is an empty state, not an error banner.
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCapabilities(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.id, running, project.container_id]);
|
||||||
|
|
||||||
|
const openGroup: CapabilityGroup | null =
|
||||||
|
open && capabilities ? capabilities[open] : null;
|
||||||
|
const openLabel = GROUPS.find((g) => g.key === open)?.label ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-2">
|
||||||
|
Capabilities
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to read its skills, agents, commands, hooks and plugins.
|
||||||
|
</p>
|
||||||
|
) : loading && !capabilities ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Reading container volume…</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{GROUPS.map(({ key, label }) => {
|
||||||
|
const count = capabilities?.[key].count ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
disabled={count === 0}
|
||||||
|
onClick={() => setOpen(key)}
|
||||||
|
className="flex items-baseline gap-2 px-3 py-2 min-w-[7.5rem] text-left bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] hover:border-[var(--accent)] disabled:hover:border-[var(--border-color)] disabled:cursor-default transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-lg font-semibold tabular-nums ${
|
||||||
|
count === 0 ? "text-[var(--text-disabled)]" : "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && openGroup && (
|
||||||
|
<Modal
|
||||||
|
title={`${openLabel} — ${project.name}`}
|
||||||
|
description={
|
||||||
|
SLASH_HINT[open]
|
||||||
|
? `Claude Code manages these with ${SLASH_HINT[open]}.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
widthClassName="w-[34rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(null);
|
||||||
|
// Claude Code owns the editors; we just deep-link into them.
|
||||||
|
onManageInTerminal("claude");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage in terminal
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setOpen(null)}>Close</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{openGroup.items.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">Nothing configured.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{openGroup.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`${item.name}-${i}`}
|
||||||
|
className="pb-2 border-b border-[var(--border-color)] last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-primary)] font-mono">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)]">
|
||||||
|
{item.scope}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import type { SaveState } from "../../../hooks/useSaveState";
|
||||||
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
|
import WorkspaceSection from "./config/WorkspaceSection";
|
||||||
|
import ModelSection from "./config/ModelSection";
|
||||||
|
import AccessSection from "./config/AccessSection";
|
||||||
|
import RuntimeSection from "./config/RuntimeSection";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
saveState: SaveState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOPPED_ONLY =
|
||||||
|
"Container must be stopped to change this setting.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the seven config modals used to hold, full-width and grouped.
|
||||||
|
* Saves happen on blur; the indicator in the header reports the outcome.
|
||||||
|
*/
|
||||||
|
export default function ConfigTab({ project, save, saveState }: Props) {
|
||||||
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
const disabled = !isStopped;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between gap-4 min-h-[1.5rem]">
|
||||||
|
{disabled ? (
|
||||||
|
<p className="px-2 py-1 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]">
|
||||||
|
Container is {project.status} — stop it to change these settings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Changes save when a field loses focus.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceSection project={project} save={save} disabled={disabled} />
|
||||||
|
<ModelSection project={project} save={save} disabled={disabled} />
|
||||||
|
<AccessSection
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={STOPPED_ONLY}
|
||||||
|
/>
|
||||||
|
<RuntimeSection
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={STOPPED_ONLY}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The old 42rem FileManager popup, now a main-area section. */
|
||||||
|
export default function FilesTab({ project }: Props) {
|
||||||
|
const {
|
||||||
|
currentPath,
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
navigate,
|
||||||
|
goUp,
|
||||||
|
refresh,
|
||||||
|
downloadFile,
|
||||||
|
uploadFile,
|
||||||
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running) navigate("/workspace");
|
||||||
|
// Re-list when the container comes up.
|
||||||
|
}, [navigate, running]);
|
||||||
|
|
||||||
|
const breadcrumbs =
|
||||||
|
currentPath === "/"
|
||||||
|
? [{ label: "/", path: "/" }]
|
||||||
|
: currentPath
|
||||||
|
.split("/")
|
||||||
|
.reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
acc.push({ label: "/", path: "/" });
|
||||||
|
} else if (part) {
|
||||||
|
const parentPath = acc[acc.length - 1].path;
|
||||||
|
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||||
|
acc.push({ label: part, path: fullPath });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to browse its files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||||
|
<nav aria-label="Path" className="flex items-center gap-1">
|
||||||
|
{breadcrumbs.map((crumb, i) => (
|
||||||
|
<span key={crumb.path} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(crumb.path)}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button onClick={uploadFile}>Upload file</Button>
|
||||||
|
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{currentPath !== "/" && (
|
||||||
|
<tr
|
||||||
|
onClick={goUp}
|
||||||
|
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
||||||
|
<td colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr
|
||||||
|
key={entry.name}
|
||||||
|
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||||
|
className={`${
|
||||||
|
entry.is_directory ? "cursor-pointer" : ""
|
||||||
|
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5">
|
||||||
|
<span
|
||||||
|
className={`font-mono ${
|
||||||
|
entry.is_directory
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.is_directory ? "📁 " : ""}
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||||
|
{!entry.is_directory && formatBytes(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
|
{entry.modified}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{!entry.is_directory && (
|
||||||
|
<Button
|
||||||
|
aria-label={`Download ${entry.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadFile(entry);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
Empty directory
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ClaudeSession, Project, ScheduledTask } from "../../../lib/types";
|
||||||
|
import {
|
||||||
|
listClaudeSessions,
|
||||||
|
listScheduledTasks,
|
||||||
|
getSchedulerNotifications,
|
||||||
|
resumeSessionCommand,
|
||||||
|
} from "../../../lib/tauri-commands";
|
||||||
|
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import type { SaveState } from "../../../hooks/useSaveState";
|
||||||
|
import PermissionModeControl, {
|
||||||
|
permissionModePatch,
|
||||||
|
} from "../PermissionModeControl";
|
||||||
|
import CapabilityTiles from "./CapabilityTiles";
|
||||||
|
import SaveIndicator from "../../ui/SaveIndicator";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatAge } from "./format";
|
||||||
|
import type { ProjectHomeTabId } from "./ProjectHome";
|
||||||
|
|
||||||
|
const BACKEND_LABEL: Record<Project["backend"], string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
bedrock: "AWS Bedrock",
|
||||||
|
ollama: "Ollama",
|
||||||
|
open_ai_compatible: "OpenAI Compatible",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
saveState: SaveState;
|
||||||
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
|
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewTab({
|
||||||
|
project,
|
||||||
|
save,
|
||||||
|
saveState,
|
||||||
|
actions,
|
||||||
|
onOpenTab,
|
||||||
|
}: Props) {
|
||||||
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||||
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
setSessions([]);
|
||||||
|
setTasks([]);
|
||||||
|
setNotificationCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
// All three degrade to empty when the container is unreachable.
|
||||||
|
listClaudeSessions(project.id)
|
||||||
|
.then((s) => !cancelled && setSessions(s.slice(0, 4)))
|
||||||
|
.catch(() => !cancelled && setSessions([]));
|
||||||
|
listScheduledTasks(project.id)
|
||||||
|
.then((t) => !cancelled && setTasks(t))
|
||||||
|
.catch(() => !cancelled && setTasks([]));
|
||||||
|
getSchedulerNotifications(project.id)
|
||||||
|
.then((n) => !cancelled && setNotificationCount(n.length))
|
||||||
|
.catch(() => !cancelled && setNotificationCount(0));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [project.id, running, project.container_id]);
|
||||||
|
|
||||||
|
const handleResume = async (session: ClaudeSession) => {
|
||||||
|
try {
|
||||||
|
const command = await resumeSessionCommand(project.id, session.id);
|
||||||
|
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to build the resume command:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 max-w-4xl">
|
||||||
|
{/* Permission mode — the hero control */}
|
||||||
|
<section className="p-3 border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<PermissionModeControl
|
||||||
|
project={project}
|
||||||
|
disabled={!running && project.status !== "stopped" && project.status !== "error"}
|
||||||
|
onChange={(mode) => save(permissionModePatch(mode))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex flex-wrap gap-x-6 gap-y-1 text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Backend{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{BACKEND_LABEL[project.backend]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Docker access{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{project.allow_docker_access ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
Mission Control{" "}
|
||||||
|
<span className="text-[var(--text-primary)] font-medium">
|
||||||
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("config")}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Edit configuration →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CapabilityTiles
|
||||||
|
project={project}
|
||||||
|
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* Recent sessions */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Recent sessions
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("sessions")}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
All sessions →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to list saved conversations.
|
||||||
|
</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No sessions yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||||
|
{session.name ?? session.summary ?? session.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
|
||||||
|
{formatAge(session.last_modified) ?? ""}
|
||||||
|
</span>
|
||||||
|
<Button onClick={() => handleResume(session)}>Resume</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Scheduled tasks */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
Scheduled tasks
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("automation")}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Automation →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Start the container to list scheduled tasks.
|
||||||
|
</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No scheduled tasks.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tasks.slice(0, 4).map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||||
|
{task.at ?? task.schedule}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{notificationCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTab("automation")}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
{notificationCount} notification{notificationCount === 1 ? "" : "s"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
|
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||||
|
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import OverflowMenu from "../../ui/OverflowMenu";
|
||||||
|
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||||
|
import OverviewTab from "./OverviewTab";
|
||||||
|
import SessionsTab from "./SessionsTab";
|
||||||
|
import AutomationTab from "./AutomationTab";
|
||||||
|
import ConfigTab from "./ConfigTab";
|
||||||
|
import FilesTab from "./FilesTab";
|
||||||
|
import { formatUptime } from "./format";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "sessions", label: "Sessions" },
|
||||||
|
{ id: "automation", label: "Automation" },
|
||||||
|
{ id: "config", label: "Config" },
|
||||||
|
{ id: "files", label: "Files" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project promoted from a sidebar card to a first-class main-area view.
|
||||||
|
* Everything that used to spray out of `ProjectCard` as a modal lives here.
|
||||||
|
*/
|
||||||
|
export default function ProjectHome({ projectId, active }: Props) {
|
||||||
|
const { projects, remove } = useProjects();
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const { runningSince, progress } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
runningSince: s.runningSince[projectId],
|
||||||
|
progress: s.containerProgress[projectId],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render once a minute so the uptime line stays honest.
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || runningSince === undefined) return;
|
||||||
|
const timer = setInterval(() => setTick((t) => t + 1), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [active, runningSince]);
|
||||||
|
|
||||||
|
const actions = useProjectActions(
|
||||||
|
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||||
|
);
|
||||||
|
const { save, saveState } = useProjectSave(
|
||||||
|
project ?? ({ id: projectId, name: "" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex items-center justify-center ${active ? "" : "hidden"}`}>
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
This project is no longer available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRunning = project.status === "running";
|
||||||
|
const isTransitioning =
|
||||||
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base font-semibold text-[var(--text-primary)] truncate">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-xs">
|
||||||
|
<ProjectStatusIndicator status={project.status} />
|
||||||
|
{isRunning && uptime && (
|
||||||
|
<span className="text-[var(--text-secondary)]">· {uptime}</span>
|
||||||
|
)}
|
||||||
|
{isTransitioning && progress && (
|
||||||
|
<span className="text-[var(--warning)] truncate">· {progress}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{isRunning ? (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={actions.busy}
|
||||||
|
onClick={actions.openClaudeTerminal}
|
||||||
|
>
|
||||||
|
Open Claude Terminal
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={actions.busy || isTransitioning}
|
||||||
|
onClick={actions.handleStart}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isRunning && (
|
||||||
|
<>
|
||||||
|
<Button size="md" onClick={actions.openShell}>
|
||||||
|
Shell
|
||||||
|
</Button>
|
||||||
|
<Button size="md" onClick={() => setTab("files")}>
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
|
<Button size="md" disabled={actions.busy} onClick={actions.handleStop}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isTransitioning && (
|
||||||
|
<Button size="md" variant="danger" onClick={actions.handleStop}>
|
||||||
|
Force stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<OverflowMenu
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: actions.backingUp ? "Backing up…" : "Back up container",
|
||||||
|
onSelect: actions.handleBackup,
|
||||||
|
disabled: actions.backingUp || !project.container_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reset container",
|
||||||
|
onSelect: actions.handleReset,
|
||||||
|
disabled: !isStopped || actions.busy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remove project…",
|
||||||
|
onSelect: () => setConfirmRemove(true),
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div role="tablist" aria-label="Project sections" className="flex gap-1 mt-3 -mb-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`project-tab-${projectId}-${t.id}`}
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
aria-controls={`project-panel-${projectId}-${t.id}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? "text-[var(--text-primary)] border-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id={`project-panel-${projectId}-${tab}`}
|
||||||
|
aria-labelledby={`project-tab-${projectId}-${tab}`}
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{tab === "overview" && (
|
||||||
|
<OverviewTab
|
||||||
|
project={project}
|
||||||
|
save={save}
|
||||||
|
saveState={saveState}
|
||||||
|
actions={actions}
|
||||||
|
onOpenTab={setTab}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||||
|
{tab === "automation" && <AutomationTab project={project} />}
|
||||||
|
{tab === "config" && (
|
||||||
|
<ConfigTab project={project} save={save} saveState={saveState} />
|
||||||
|
)}
|
||||||
|
{tab === "files" && <FilesTab project={project} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmRemove && (
|
||||||
|
<ConfirmRemoveModal
|
||||||
|
projectName={project.name}
|
||||||
|
onCancel={() => setConfirmRemove(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setConfirmRemove(false);
|
||||||
|
try {
|
||||||
|
await remove(project.id);
|
||||||
|
} catch (e) {
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not remove “${project.name}”`,
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ClaudeSession, Project } from "../../../lib/types";
|
||||||
|
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
|
||||||
|
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
import { formatAge, formatBytes } from "./format";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
actions: ReturnType<typeof useProjectActions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stop/start container model buries "which conversation was I in?" in the
|
||||||
|
* config volume. This lists it and makes [Resume] one click.
|
||||||
|
*/
|
||||||
|
export default function SessionsTab({ project, actions }: Props) {
|
||||||
|
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const running = project.status === "running";
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!running) {
|
||||||
|
setSessions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
listClaudeSessions(project.id)
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => setSessions([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [project.id, running]);
|
||||||
|
|
||||||
|
useEffect(load, [load]);
|
||||||
|
|
||||||
|
const resume = async (session: ClaudeSession) => {
|
||||||
|
try {
|
||||||
|
const command = await resumeSessionCommand(project.id, session.id);
|
||||||
|
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not resume that session",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Conversations stored on this project’s config volume. Resume opens a
|
||||||
|
terminal running the resume command.
|
||||||
|
</p>
|
||||||
|
<Button onClick={load} disabled={!running || loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Start the container to read its saved sessions.
|
||||||
|
</p>
|
||||||
|
) : sessions.length === 0 && !loading ? (
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
No sessions recorded yet. Open a Claude terminal to start one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] text-[var(--text-primary)] truncate">
|
||||||
|
{session.name ?? session.summary ?? "(untitled session)"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] truncate font-mono">
|
||||||
|
{session.id}
|
||||||
|
{session.cwd ? ` · ${session.cwd}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||||
|
<div>{formatAge(session.last_modified) ?? "—"}</div>
|
||||||
|
<div>
|
||||||
|
{formatBytes(session.size_bytes)} · {session.message_count} msg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => resume(session)}>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { Project } from "../../../../lib/types";
|
||||||
|
import Button from "../../../ui/Button";
|
||||||
|
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
||||||
|
import EnvVarsEditor from "../../EnvVarsEditor";
|
||||||
|
import PortMappingsEditor from "../../PortMappingsEditor";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessSection({
|
||||||
|
project,
|
||||||
|
save,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
}: Props) {
|
||||||
|
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 ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
|
setGitName(project.git_user_name ?? "");
|
||||||
|
setGitEmail(project.git_user_email ?? "");
|
||||||
|
setGitToken(project.git_token ?? "");
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigGroup
|
||||||
|
title="Access"
|
||||||
|
description="Credentials, environment, and networking the container is given."
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="SSH key directory"
|
||||||
|
hint="Mounted into the container so Claude can authenticate with Git remotes over SSH."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={sshKeyPath}
|
||||||
|
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||||
|
onBlur={() => save({ ssh_key_path: sshKeyPath || null })}
|
||||||
|
placeholder="~/.ssh"
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
setSshKeyPath(selected);
|
||||||
|
save({ ssh_key_path: selected });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Git name" hint="Sets git user.name inside the container for commit authorship.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={gitName}
|
||||||
|
onChange={(e) => setGitName(e.target.value)}
|
||||||
|
onBlur={() => save({ git_user_name: gitName || null })}
|
||||||
|
placeholder="Your Name"
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Git email" hint="Sets git user.email inside the container for commit authorship.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={gitEmail}
|
||||||
|
onChange={(e) => setGitEmail(e.target.value)}
|
||||||
|
onBlur={() => save({ git_user_email: gitEmail || null })}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Git HTTPS token"
|
||||||
|
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={gitToken}
|
||||||
|
onChange={(e) => setGitToken(e.target.value)}
|
||||||
|
onBlur={() => save({ git_token: gitToken || null })}
|
||||||
|
placeholder="ghp_…"
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
Environment variables
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Injected into this project’s container. These override global variables
|
||||||
|
with the same key.
|
||||||
|
</p>
|
||||||
|
<EnvVarsEditor
|
||||||
|
envVars={project.custom_env_vars ?? []}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onSave={(vars) => save({ custom_env_vars: vars })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
Port mappings
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Expose container ports on the host so you can reach dev servers running inside
|
||||||
|
the sandbox.
|
||||||
|
</p>
|
||||||
|
<PortMappingsEditor
|
||||||
|
portMappings={project.port_mappings ?? []}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onSave={(mappings) => save({ port_mappings: mappings })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ConfigGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
Backend,
|
||||||
|
BedrockAuthMethod,
|
||||||
|
BedrockConfig,
|
||||||
|
OllamaConfig,
|
||||||
|
OpenAiCompatibleConfig,
|
||||||
|
Project,
|
||||||
|
} from "../../../../lib/types";
|
||||||
|
import Field, { ConfigGroup, monoInputClass, selectClass } from "../../../ui/Field";
|
||||||
|
|
||||||
|
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||||
|
auth_method: "static_credentials",
|
||||||
|
aws_region: "us-east-1",
|
||||||
|
aws_access_key_id: null,
|
||||||
|
aws_secret_access_key: null,
|
||||||
|
aws_session_token: null,
|
||||||
|
aws_profile: null,
|
||||||
|
aws_bearer_token: null,
|
||||||
|
model_id: null,
|
||||||
|
disable_prompt_caching: false,
|
||||||
|
service_tier: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||||
|
base_url: "http://host.docker.internal:11434",
|
||||||
|
model_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||||
|
base_url: "http://host.docker.internal:4000",
|
||||||
|
api_key: null,
|
||||||
|
model_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelSection({ project, save, disabled }: Props) {
|
||||||
|
const bedrock = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||||
|
|
||||||
|
// Local text state — saved on blur, not on every keystroke.
|
||||||
|
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||||
|
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
||||||
|
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
||||||
|
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
||||||
|
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
||||||
|
const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? "");
|
||||||
|
const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||||
|
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
||||||
|
|
||||||
|
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(
|
||||||
|
project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url,
|
||||||
|
);
|
||||||
|
const [ollamaModelId, setOllamaModelId] = useState(
|
||||||
|
project.ollama_config?.model_id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||||
|
project.openai_compatible_config?.base_url ??
|
||||||
|
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||||
|
);
|
||||||
|
const [oaiApiKey, setOaiApiKey] = useState(
|
||||||
|
project.openai_compatible_config?.api_key ?? "",
|
||||||
|
);
|
||||||
|
const [oaiModelId, setOaiModelId] = useState(
|
||||||
|
project.openai_compatible_config?.model_id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||||
|
setBedrockRegion(bc.aws_region);
|
||||||
|
setAccessKeyId(bc.aws_access_key_id ?? "");
|
||||||
|
setSecretKey(bc.aws_secret_access_key ?? "");
|
||||||
|
setSessionToken(bc.aws_session_token ?? "");
|
||||||
|
setProfile(bc.aws_profile ?? "");
|
||||||
|
setBearerToken(bc.aws_bearer_token ?? "");
|
||||||
|
setBedrockModelId(bc.model_id ?? "");
|
||||||
|
setServiceTier(bc.service_tier ?? "");
|
||||||
|
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||||
|
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||||
|
setOaiBaseUrl(
|
||||||
|
project.openai_compatible_config?.base_url ??
|
||||||
|
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||||
|
);
|
||||||
|
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||||
|
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||||
|
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||||
|
|
||||||
|
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||||
|
save({
|
||||||
|
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||||
|
save({
|
||||||
|
openai_compatible_config: {
|
||||||
|
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBackendChange = (mode: Backend) => {
|
||||||
|
const patch: Partial<Project> = { backend: mode };
|
||||||
|
if (mode === "bedrock" && !project.bedrock_config)
|
||||||
|
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||||
|
if (mode === "ollama" && !project.ollama_config)
|
||||||
|
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||||
|
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||||
|
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||||
|
save(patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||||
|
<Field
|
||||||
|
label="Backend"
|
||||||
|
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={project.backend}
|
||||||
|
onChange={(e) => handleBackendChange(e.target.value as Backend)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
<option value="bedrock">Bedrock</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{project.backend === "bedrock" && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<Field label="Authentication method" hint="How the container proves its identity to Bedrock.">
|
||||||
|
{(id) => (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={bedrock.auth_method}
|
||||||
|
onChange={(e) =>
|
||||||
|
saveBedrock({ auth_method: e.target.value as BedrockAuthMethod })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="static_credentials">Static keys</option>
|
||||||
|
<option value="profile">Named profile</option>
|
||||||
|
<option value="bearer_token">Bearer token</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="AWS region" hint="Region where your Bedrock endpoint is available.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={bedrockRegion}
|
||||||
|
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ aws_region: bedrockRegion })}
|
||||||
|
placeholder="us-east-1"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{bedrock.auth_method === "static_credentials" && (
|
||||||
|
<>
|
||||||
|
<Field label="Access key ID" hint="IAM access key used for Bedrock API calls.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={accessKeyId}
|
||||||
|
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
||||||
|
placeholder="AKIA…"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Secret access key"
|
||||||
|
hint="Stored locally and injected as an env var into the container."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={secretKey}
|
||||||
|
onChange={(e) => setSecretKey(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
saveBedrock({ aws_secret_access_key: secretKey || null })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Session token"
|
||||||
|
hint="Optional — for assumed-role or MFA-based credentials."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={sessionToken}
|
||||||
|
onChange={(e) => setSessionToken(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
saveBedrock({ aws_session_token: sessionToken || null })
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bedrock.auth_method === "profile" && (
|
||||||
|
<Field
|
||||||
|
label="AWS profile"
|
||||||
|
hint="Named profile from your AWS config/credentials files."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={profile}
|
||||||
|
onChange={(e) => setProfile(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ aws_profile: profile || null })}
|
||||||
|
placeholder="default"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bedrock.auth_method === "bearer_token" && (
|
||||||
|
<Field
|
||||||
|
label="Bearer token"
|
||||||
|
hint="SSO or identity-center token for Bedrock authentication."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={bearerToken}
|
||||||
|
onChange={(e) => setBearerToken(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Model ID" hint="Optional override. Leave blank for Claude's default.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={bedrockModelId}
|
||||||
|
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ model_id: bedrockModelId || null })}
|
||||||
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Service tier"
|
||||||
|
hint="Optional — sets ANTHROPIC_BEDROCK_SERVICE_TIER (e.g. “priority”)."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={serviceTier}
|
||||||
|
onChange={(e) => setServiceTier(e.target.value)}
|
||||||
|
onBlur={() => saveBedrock({ service_tier: serviceTier.trim() || null })}
|
||||||
|
placeholder="(account default)"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.backend === "ollama" && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<Field
|
||||||
|
label="Base URL"
|
||||||
|
hint="Use host.docker.internal to reach the host machine, or an IP/hostname for a remote server."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={ollamaBaseUrl}
|
||||||
|
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||||
|
onBlur={() => saveOllama({ base_url: ollamaBaseUrl })}
|
||||||
|
placeholder="http://host.docker.internal:11434"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Model"
|
||||||
|
hint="Required. The model must already be pulled in Ollama before the container starts."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={ollamaModelId}
|
||||||
|
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||||
|
onBlur={() => saveOllama({ model_id: ollamaModelId || null })}
|
||||||
|
placeholder="qwen3.5:27b"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.backend === "open_ai_compatible" && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||||
|
<Field
|
||||||
|
label="Base URL"
|
||||||
|
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={oaiBaseUrl}
|
||||||
|
onChange={(e) => setOaiBaseUrl(e.target.value)}
|
||||||
|
onBlur={() => saveOpenAi({ base_url: oaiBaseUrl })}
|
||||||
|
placeholder="http://host.docker.internal:4000"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="API key" hint="Authentication key for the endpoint, if it requires one.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={oaiApiKey}
|
||||||
|
onChange={(e) => setOaiApiKey(e.target.value)}
|
||||||
|
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Model" hint="Optional — model identifier as configured by your provider.">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={oaiModelId}
|
||||||
|
onChange={(e) => setOaiModelId(e.target.value)}
|
||||||
|
onBlur={() => saveOpenAi({ model_id: oaiModelId || null })}
|
||||||
|
placeholder="gpt-4o / gemini-pro / …"
|
||||||
|
disabled={disabled}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfigGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Project } from "../../../../lib/types";
|
||||||
|
import Toggle from "../../../ui/Toggle";
|
||||||
|
import { ConfigGroup, SwitchRow } from "../../../ui/Field";
|
||||||
|
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||||
|
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||||
|
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuntimeSection({
|
||||||
|
project,
|
||||||
|
save,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigGroup
|
||||||
|
title="Runtime"
|
||||||
|
description="How much the sandbox lets Claude do, and what contains it."
|
||||||
|
>
|
||||||
|
<div className="pb-2 border-b border-[var(--border-color)]">
|
||||||
|
<PermissionModeControl
|
||||||
|
project={project}
|
||||||
|
onChange={(mode) => save(permissionModePatch(mode))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Sandbox mode"
|
||||||
|
hint="Claude Code's bash sandbox (bubblewrap filesystem and network isolation). Triple-C is the source of truth: toggling this overrides any manual /sandbox configuration in the container's settings.json on next start."
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Sandbox mode"
|
||||||
|
checked={project.sandbox_mode_enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => save({ sandbox_mode_enabled: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Allow container spawning"
|
||||||
|
hint="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox."
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Allow container spawning"
|
||||||
|
checked={project.allow_docker_access}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => save({ allow_docker_access: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchRow
|
||||||
|
label="Mission Control"
|
||||||
|
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||||
|
control={
|
||||||
|
<Toggle
|
||||||
|
label="Mission Control"
|
||||||
|
checked={project.mission_control_enabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => save({ mission_control_enabled: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{disabled && disabledReason && (
|
||||||
|
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||||
|
)}
|
||||||
|
</ConfigGroup>
|
||||||
|
|
||||||
|
<ConfigGroup
|
||||||
|
title="Claude instructions"
|
||||||
|
description="Written to ~/.claude/CLAUDE.md inside this project's container."
|
||||||
|
>
|
||||||
|
<ClaudeInstructionsEditor
|
||||||
|
instructions={project.claude_instructions ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onSave={(value) => save({ claude_instructions: value || null })}
|
||||||
|
/>
|
||||||
|
</ConfigGroup>
|
||||||
|
|
||||||
|
<ConfigGroup
|
||||||
|
title="Claude Code settings"
|
||||||
|
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||||
|
>
|
||||||
|
<ClaudeCodeSettingsEditor
|
||||||
|
settings={project.claude_code_settings}
|
||||||
|
disabled={disabled}
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onSave={(settings) => save({ claude_code_settings: settings })}
|
||||||
|
/>
|
||||||
|
</ConfigGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { Project, ProjectPath } from "../../../../lib/types";
|
||||||
|
import Button from "../../../ui/Button";
|
||||||
|
import Field, { ConfigGroup, inputClass, monoInputClass } from "../../../ui/Field";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||||
|
const [name, setName] = useState(project.name);
|
||||||
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(project.name);
|
||||||
|
setPaths(project.paths ?? []);
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigGroup
|
||||||
|
title="Workspace"
|
||||||
|
description="What this sandbox is called and which host folders it can see."
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="Project name"
|
||||||
|
hint="Shown in the sidebar and on terminal tabs."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setName(project.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed !== project.name) save({ name: trimmed });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") setName(project.name);
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||||
|
Folders
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Each host folder is mounted at <span className="font-mono">/workspace/<name></span>{" "}
|
||||||
|
inside the container.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paths.map((pp, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-1.5 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
value={pp.host_path}
|
||||||
|
aria-label={`Folder ${i + 1} host path`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...paths];
|
||||||
|
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
onBlur={() => save({ paths })}
|
||||||
|
placeholder="/path/to/folder"
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 min-w-0 ${inputClass}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
disabled={disabled}
|
||||||
|
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);
|
||||||
|
save({ paths: updated });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||||
|
/workspace/
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={pp.mount_name}
|
||||||
|
aria-label={`Folder ${i + 1} mount name`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...paths];
|
||||||
|
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||||
|
setPaths(updated);
|
||||||
|
}}
|
||||||
|
onBlur={() => save({ paths })}
|
||||||
|
placeholder="name"
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-40 ${monoInputClass}`}
|
||||||
|
/>
|
||||||
|
{paths.length > 1 && (
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`Remove folder ${i + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
const updated = paths.filter((_, j) => j !== i);
|
||||||
|
setPaths(updated);
|
||||||
|
save({ paths: updated });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-2"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setPaths([...paths, { host_path: "", mount_name: "" }])}
|
||||||
|
>
|
||||||
|
+ Add folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ConfigGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** Shared formatting helpers for the Project Home views. */
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
|
||||||
|
export function formatAge(iso: string | null | undefined): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const then = Date.parse(iso);
|
||||||
|
if (Number.isNaN(then)) return null;
|
||||||
|
return formatElapsed(Date.now() - then);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsed(ms: number): string {
|
||||||
|
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ${minutes % 60}m ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uptime phrasing for a known start timestamp. */
|
||||||
|
export function formatUptime(startedAtMs: number | undefined): string | null {
|
||||||
|
if (startedAtMs === undefined) return null;
|
||||||
|
const seconds = Math.floor((Date.now() - startedAtMs) / 1000);
|
||||||
|
if (seconds < 60) return "just started";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `up ${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `up ${hours}h ${minutes % 60}m`;
|
||||||
|
return `up ${Math.floor(hours / 24)}d`;
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default function AwsSettings() {
|
|||||||
value={globalAws.aws_config_path ?? ""}
|
value={globalAws.aws_config_path ?? ""}
|
||||||
onChange={(e) => handleChange("aws_config_path", e.target.value)}
|
onChange={(e) => handleChange("aws_config_path", e.target.value)}
|
||||||
placeholder="~/.aws"
|
placeholder="~/.aws"
|
||||||
className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleDetect}
|
onClick={handleDetect}
|
||||||
@@ -86,7 +86,7 @@ export default function AwsSettings() {
|
|||||||
<select
|
<select
|
||||||
value={globalAws.aws_profile ?? ""}
|
value={globalAws.aws_profile ?? ""}
|
||||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
>
|
>
|
||||||
<option value="">None (use default)</option>
|
<option value="">None (use default)</option>
|
||||||
{profiles.map((p) => (
|
{profiles.map((p) => (
|
||||||
@@ -103,7 +103,7 @@ export default function AwsSettings() {
|
|||||||
value={globalAws.aws_region ?? ""}
|
value={globalAws.aws_region ?? ""}
|
||||||
onChange={(e) => handleChange("aws_region", e.target.value)}
|
onChange={(e) => handleChange("aws_region", e.target.value)}
|
||||||
placeholder="e.g., us-east-1"
|
placeholder="e.g., us-east-1"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export default function AwsSettings() {
|
|||||||
value={globalAws.default_model_id ?? ""}
|
value={globalAws.default_model_id ?? ""}
|
||||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function DockerSettings() {
|
|||||||
onClick={() => handleSourceChange(opt.value)}
|
onClick={() => handleSourceChange(opt.value)}
|
||||||
className={`flex-1 px-2 py-1.5 text-xs rounded border transition-colors ${
|
className={`flex-1 px-2 py-1.5 text-xs rounded border transition-colors ${
|
||||||
imageSource === opt.value
|
imageSource === opt.value
|
||||||
? "bg-[var(--accent)] text-white border-[var(--accent)]"
|
? "bg-[var(--accent-emphasis)] text-white border-[var(--accent)]"
|
||||||
: "bg-[var(--bg-tertiary)] border-[var(--border-color)] hover:bg-[var(--border-color)]"
|
: "bg-[var(--bg-tertiary)] border-[var(--border-color)] hover:bg-[var(--border-color)]"
|
||||||
}`}
|
}`}
|
||||||
title={opt.description}
|
title={opt.description}
|
||||||
@@ -116,7 +116,7 @@ export default function DockerSettings() {
|
|||||||
value={customInput}
|
value={customInput}
|
||||||
onChange={(e) => handleCustomChange(e.target.value)}
|
onChange={(e) => handleCustomChange(e.target.value)}
|
||||||
placeholder="e.g., myregistry.com/image:tag"
|
placeholder="e.g., myregistry.com/image:tag"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -149,7 +149,7 @@ export default function DockerSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleBuild}
|
onClick={handleBuild}
|
||||||
disabled={working || !dockerAvailable}
|
disabled={working || !dockerAvailable}
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
className="px-3 py-1.5 text-xs bg-[var(--accent-emphasis)] text-white rounded hover:bg-[var(--accent-emphasis-hover)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
{working ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
{working ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
||||||
</button>
|
</button>
|
||||||
@@ -157,7 +157,7 @@ export default function DockerSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={working || !dockerAvailable}
|
disabled={working || !dockerAvailable}
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
className="px-3 py-1.5 text-xs bg-[var(--accent-emphasis)] text-white rounded hover:bg-[var(--accent-emphasis-hover)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
{working ? "Pulling..." : imageExists ? "Re-pull Image" : "Pull Image"}
|
{working ? "Pulling..." : imageExists ? "Re-pull Image" : "Pull Image"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import type { ImageUpdateInfo } from "../../lib/types";
|
import type { ImageUpdateInfo } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
imageUpdateInfo: ImageUpdateInfo;
|
imageUpdateInfo: ImageUpdateInfo;
|
||||||
@@ -12,23 +13,6 @@ export default function ImageUpdateDialog({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: 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 shortDigest = (digest: string) => {
|
const shortDigest = (digest: string) => {
|
||||||
// Show first 16 chars of the hash part (after "sha256:")
|
// Show first 16 chars of the hash part (after "sha256:")
|
||||||
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
|
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
|
||||||
@@ -36,56 +20,45 @@ export default function ImageUpdateDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Container Image Update"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[30rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onDismiss}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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">
|
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||||
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
|
A newer version of the container image is available in the registry. Re-pull the
|
||||||
|
image in Docker settings to get the latest tools and fixes.
|
||||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
|
||||||
A newer version of the container image is available in the registry.
|
|
||||||
Re-pull the image in Docker settings to get the latest tools and fixes.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
|
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded-[var(--radius-control)] p-3 border border-[var(--border-color)]">
|
||||||
{imageUpdateInfo.local_digest && (
|
{imageUpdateInfo.local_digest && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[var(--text-secondary)]">Local digest</span>
|
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||||
<span className="font-mono text-[var(--text-primary)]">
|
<span className="font-mono text-[var(--text-primary)]">
|
||||||
{shortDigest(imageUpdateInfo.local_digest)}...
|
{shortDigest(imageUpdateInfo.local_digest)}…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||||
<span className="font-mono text-[var(--accent)]">
|
<span className="font-mono text-[var(--accent)]">
|
||||||
{shortDigest(imageUpdateInfo.remote_digest)}...
|
{shortDigest(imageUpdateInfo.remote_digest)}…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
Go to Settings > Docker and click "Re-pull Image" to update.
|
Go to Settings > Container and click "Re-pull Image" to update.
|
||||||
Running containers will not be affected until restarted.
|
Running containers will not be affected until restarted.
|
||||||
</p>
|
</p>
|
||||||
|
</Modal>
|
||||||
<div className="flex items-center justify-end 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function MicrophoneSettings() {
|
|||||||
value={selected}
|
value={selected}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
>
|
>
|
||||||
<option value="">System Default</option>
|
<option value="">System Default</option>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
@@ -90,7 +90,7 @@ export default function MicrophoneSettings() {
|
|||||||
onClick={enumerateDevices}
|
onClick={enumerateDevices}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="Refresh microphone list"
|
title="Refresh microphone list"
|
||||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:text-[var(--text-disabled)]"
|
||||||
>
|
>
|
||||||
{loading ? "..." : "Refresh"}
|
{loading ? "..." : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function OllamaSettings() {
|
|||||||
value={globalOllama.base_url ?? ""}
|
value={globalOllama.base_url ?? ""}
|
||||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||||
placeholder="http://host.docker.internal:11434"
|
placeholder="http://host.docker.internal:11434"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export default function OllamaSettings() {
|
|||||||
value={globalOllama.default_model_id ?? ""}
|
value={globalOllama.default_model_id ?? ""}
|
||||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||||
placeholder="qwen3.5:27b"
|
placeholder="qwen3.5:27b"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function OpenAiCompatibleSettings() {
|
|||||||
value={globalOai.base_url ?? ""}
|
value={globalOai.base_url ?? ""}
|
||||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||||
placeholder="http://host.docker.internal:4000"
|
placeholder="http://host.docker.internal:4000"
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export default function OpenAiCompatibleSettings() {
|
|||||||
value={globalOai.default_model_id ?? ""}
|
value={globalOai.default_model_id ?? ""}
|
||||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||||
placeholder="gpt-4o / gemini-pro / etc."
|
placeholder="gpt-4o / gemini-pro / etc."
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { detectHostTimezone } from "../../lib/tauri-commands";
|
|||||||
import type { EnvVar } from "../../lib/types";
|
import type { EnvVar } from "../../lib/types";
|
||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
import AccordionSection from "../ui/AccordionSection";
|
import AccordionSection from "../ui/AccordionSection";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="UTC"
|
placeholder="UTC"
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,7 +178,7 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="~/.ssh"
|
placeholder="~/.ssh"
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Your Name"
|
placeholder="Your Name"
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +212,7 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
@@ -230,27 +231,22 @@ export default function SettingsPanel() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
|
<label className="text-xs text-[var(--text-secondary)]">Auto-check for updates</label>
|
||||||
<button
|
<Toggle
|
||||||
onClick={handleAutoCheckToggle}
|
label="Auto-check for updates"
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
checked={appSettings?.auto_check_updates !== false}
|
||||||
appSettings?.auto_check_updates !== false
|
onChange={handleAutoCheckToggle}
|
||||||
? "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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckNow}
|
onClick={handleCheckNow}
|
||||||
disabled={checkingUpdates}
|
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"
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
{checkingUpdates ? "Checking..." : "Check now"}
|
{checkingUpdates ? "Checking..." : "Check now"}
|
||||||
</button>
|
</button>
|
||||||
{imageUpdateInfo && (
|
{imageUpdateInfo && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
|
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning)] rounded">
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
|
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning)]" />
|
||||||
<span>A newer container image is available. Re-pull the image in Container settings above to update.</span>
|
<span>A newer container image is available. Re-pull the image in Container settings above to update.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getSttStatus, startStt, stopStt, pullSttImage, buildSttImage } from "..
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { SttStatus } from "../../lib/types";
|
import type { SttStatus } from "../../lib/types";
|
||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
|
||||||
export default function SttSettings() {
|
export default function SttSettings() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -130,16 +131,11 @@ export default function SttSettings() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Enable toggle */}
|
{/* Enable toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Toggle
|
||||||
onClick={handleToggleEnabled}
|
label="Speech to text"
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
checked={!!appSettings?.stt?.enabled}
|
||||||
appSettings?.stt?.enabled
|
onChange={handleToggleEnabled}
|
||||||
? "bg-[var(--success)] text-white"
|
/>
|
||||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{appSettings?.stt?.enabled ? "ON" : "OFF"}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
{appSettings?.stt?.enabled ? "Enabled" : "Disabled"}
|
{appSettings?.stt?.enabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
@@ -154,7 +150,7 @@ export default function SttSettings() {
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
onBlur={handleSaveModel}
|
onBlur={handleSaveModel}
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
>
|
>
|
||||||
<option value="tiny">Tiny (fastest, ~75MB)</option>
|
<option value="tiny">Tiny (fastest, ~75MB)</option>
|
||||||
<option value="small">Small (balanced, ~500MB)</option>
|
<option value="small">Small (balanced, ~500MB)</option>
|
||||||
@@ -172,7 +168,7 @@ export default function SttSettings() {
|
|||||||
onBlur={handleSavePort}
|
onBlur={handleSavePort}
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,7 +181,7 @@ export default function SttSettings() {
|
|||||||
onChange={(e) => setLanguage(e.target.value)}
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
onBlur={handleSaveLanguage}
|
onBlur={handleSaveLanguage}
|
||||||
placeholder="Auto-detect"
|
placeholder="Auto-detect"
|
||||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,14 +218,14 @@ export default function SttSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={pulling || building}
|
disabled={pulling || building}
|
||||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
{pulling ? "Pulling..." : "Pull Image"}
|
{pulling ? "Pulling..." : "Pull Image"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleBuild}
|
onClick={handleBuild}
|
||||||
disabled={pulling || building}
|
disabled={pulling || building}
|
||||||
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
className="px-3 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
{building ? "Building..." : "Build Locally"}
|
{building ? "Building..." : "Build Locally"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import type { UpdateInfo } from "../../lib/types";
|
import type { UpdateInfo } from "../../lib/types";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
updateInfo: UpdateInfo;
|
updateInfo: UpdateInfo;
|
||||||
@@ -15,23 +16,6 @@ export default function UpdateDialog({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: 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) => {
|
const handleDownload = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await openUrl(url);
|
await openUrl(url);
|
||||||
@@ -46,45 +30,58 @@ export default function UpdateDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
ref={overlayRef}
|
title="Update Available"
|
||||||
onClick={handleOverlayClick}
|
onClose={onClose}
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
widthClassName="w-[30rem]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mr-auto text-[var(--accent)] hover:text-[var(--accent-hover)]"
|
||||||
|
onClick={() => handleDownload(updateInfo.release_url)}
|
||||||
>
|
>
|
||||||
<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">
|
View on Gitea
|
||||||
<h2 className="text-lg font-semibold mb-3">Update Available</h2>
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onDismiss}>
|
||||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
Dismiss
|
||||||
<span className="text-[var(--text-secondary)]">{currentVersion}</span>
|
</Button>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-[13px]">
|
||||||
|
<span className="text-[var(--text-secondary)] font-mono">{currentVersion}</span>
|
||||||
<span className="text-[var(--text-secondary)]">→</span>
|
<span className="text-[var(--text-secondary)]">→</span>
|
||||||
<span className="text-[var(--accent)] font-semibold">
|
<span className="text-[var(--accent)] font-semibold font-mono">
|
||||||
{updateInfo.version}
|
{updateInfo.version}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{updateInfo.body && (
|
{updateInfo.body && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-1">
|
||||||
Release Notes
|
Release notes
|
||||||
</h3>
|
</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)]">
|
<div className="text-xs text-[var(--text-primary)] whitespace-pre-wrap bg-[var(--bg-primary)] rounded-[var(--radius-control)] p-3 max-h-48 overflow-y-auto border border-[var(--border-color)]">
|
||||||
{updateInfo.body}
|
{updateInfo.body}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{updateInfo.assets.length > 0 && (
|
{updateInfo.assets.length > 0 && (
|
||||||
<div className="mb-4 space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-xs font-semibold uppercase text-[var(--text-secondary)] mb-1">
|
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-1">
|
||||||
Downloads
|
Downloads
|
||||||
</h3>
|
</h3>
|
||||||
{updateInfo.assets.map((asset) => (
|
{updateInfo.assets.map((asset) => (
|
||||||
<button
|
<button
|
||||||
key={asset.name}
|
key={asset.name}
|
||||||
|
type="button"
|
||||||
onClick={() => handleDownload(asset.browser_download_url)}
|
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"
|
className="w-full flex items-center justify-between px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] hover:border-[var(--accent)] transition-colors"
|
||||||
>
|
>
|
||||||
<span className="truncate">{asset.name}</span>
|
<span className="truncate font-mono">{asset.name}</span>
|
||||||
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
||||||
{formatSize(asset.size)}
|
{formatSize(asset.size)}
|
||||||
</span>
|
</span>
|
||||||
@@ -92,30 +89,6 @@ export default function UpdateDialog({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
|
import { startWebTerminal, stopWebTerminal, getWebTerminalStatus, regenerateWebTerminalToken } from "../../lib/tauri-commands";
|
||||||
import type { WebTerminalInfo } from "../../lib/types";
|
import type { WebTerminalInfo } from "../../lib/types";
|
||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
import Toggle from "../ui/Toggle";
|
||||||
|
|
||||||
export default function WebTerminalSettings() {
|
export default function WebTerminalSettings() {
|
||||||
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
|
const [info, setInfo] = useState<WebTerminalInfo | null>(null);
|
||||||
@@ -68,17 +69,12 @@ export default function WebTerminalSettings() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Toggle
|
||||||
onClick={handleToggle}
|
label="Web terminal"
|
||||||
|
checked={!!info?.running}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`px-2 py-0.5 text-xs rounded transition-colors ${
|
onChange={handleToggle}
|
||||||
info?.running
|
/>
|
||||||
? "bg-[var(--success)] text-white"
|
|
||||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loading ? "..." : info?.running ? "ON" : "OFF"}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
{info?.running
|
{info?.running
|
||||||
? `Running on port ${info.port}`
|
? `Running on port ${info.port}`
|
||||||
@@ -116,7 +112,7 @@ export default function WebTerminalSettings() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRegenerate}
|
onClick={handleRegenerate}
|
||||||
className="text-xs px-2 py-0.5 text-[var(--warning,#f59e0b)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
className="text-xs px-2 py-0.5 text-[var(--warning)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
>
|
>
|
||||||
Regenerate
|
Regenerate
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
|
||||||
|
|
||||||
interface ContextMenuState {
|
|
||||||
sessionId: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalTabs() {
|
|
||||||
const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
|
|
||||||
const { projects, update } = useProjects();
|
|
||||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!menu) return;
|
|
||||||
const dismiss = () => setMenu(null);
|
|
||||||
window.addEventListener("click", dismiss);
|
|
||||||
window.addEventListener("scroll", dismiss, true);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("click", dismiss);
|
|
||||||
window.removeEventListener("scroll", dismiss, true);
|
|
||||||
};
|
|
||||||
}, [menu]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (renamingId) {
|
|
||||||
renameInputRef.current?.focus();
|
|
||||||
renameInputRef.current?.select();
|
|
||||||
}
|
|
||||||
}, [renamingId]);
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
|
||||||
No active terminals
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCustomName = (projectId: string, sessionId: string): string | null => {
|
|
||||||
const project = projects.find((p) => p.id === projectId);
|
|
||||||
return project?.renamed_session_names?.[sessionId] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRename = (sessionId: string) => {
|
|
||||||
const session = sessions.find((s) => s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
const current = getCustomName(session.projectId, sessionId) ?? session.sessionName ?? session.projectName;
|
|
||||||
setRenameDraft(current);
|
|
||||||
setRenamingId(sessionId);
|
|
||||||
setMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitRename = async (sessionId: string) => {
|
|
||||||
const session = sessions.find((s) => s.id === sessionId);
|
|
||||||
if (!session) {
|
|
||||||
setRenamingId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
|
||||||
if (!project) {
|
|
||||||
setRenamingId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = renameDraft.trim();
|
|
||||||
const map = { ...(project.renamed_session_names ?? {}) };
|
|
||||||
if (trimmed) {
|
|
||||||
map[sessionId] = trimmed;
|
|
||||||
} else {
|
|
||||||
delete map[sessionId];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await update({ ...project, renamed_session_names: map });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to rename terminal tab:", err);
|
|
||||||
} finally {
|
|
||||||
setRenamingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCustomName = async (sessionId: string) => {
|
|
||||||
const session = sessions.find((s) => s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
|
||||||
if (!project) return;
|
|
||||||
const map = { ...(project.renamed_session_names ?? {}) };
|
|
||||||
if (!(sessionId in map)) {
|
|
||||||
setMenu(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
delete map[sessionId];
|
|
||||||
try {
|
|
||||||
await update({ ...project, renamed_session_names: map });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to reset terminal tab name:", err);
|
|
||||||
} finally {
|
|
||||||
setMenu(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center h-full">
|
|
||||||
{sessions.map((session) => {
|
|
||||||
const customName = getCustomName(session.projectId, session.id);
|
|
||||||
const baseLabel =
|
|
||||||
(session.sessionName ?? session.projectName) +
|
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
const displayLabel = customName
|
|
||||||
? `${session.projectName}: ${customName}`
|
|
||||||
: baseLabel;
|
|
||||||
const isRenaming = renamingId === session.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={session.id}
|
|
||||||
onClick={() => setActiveSession(session.id)}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
|
||||||
}}
|
|
||||||
onDoubleClick={() => startRename(session.id)}
|
|
||||||
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
|
||||||
activeSessionId === session.id
|
|
||||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isRenaming ? (
|
|
||||||
<input
|
|
||||||
ref={renameInputRef}
|
|
||||||
value={renameDraft}
|
|
||||||
onChange={(e) => setRenameDraft(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onBlur={() => commitRename(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
||||||
if (e.key === "Escape") setRenamingId(null);
|
|
||||||
}}
|
|
||||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-xs text-[var(--text-primary)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="truncate max-w-[200px]" title={displayLabel}>
|
|
||||||
{displayLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
close(session.id);
|
|
||||||
}}
|
|
||||||
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors"
|
|
||||||
title="Close terminal"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{menu && (() => {
|
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
|
||||||
const hasCustom = session ? !!getCustomName(session.projectId, menu.sessionId) : false;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded shadow-lg text-xs"
|
|
||||||
style={{ top: menu.y, left: menu.x }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
|
|
||||||
onClick={() => startRename(menu.sessionId)}
|
|
||||||
>
|
|
||||||
Rename tab
|
|
||||||
</button>
|
|
||||||
{hasCustom && (
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors"
|
|
||||||
onClick={() => clearCustomName(menu.sessionId)}
|
|
||||||
>
|
|
||||||
Reset name
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-[var(--border-color)] my-1" />
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-primary)] transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
close(menu.sessionId);
|
|
||||||
setMenu(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close tab
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
export type ButtonSize = "sm" | "md";
|
||||||
|
|
||||||
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real buttons with visible bounds and a ≥24px hit target.
|
||||||
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||||
|
* `--accent` stays reserved for foreground/link use.
|
||||||
|
*/
|
||||||
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
||||||
|
secondary:
|
||||||
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
|
danger:
|
||||||
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
|
sm: "h-6 px-2 text-xs gap-1",
|
||||||
|
md: "h-8 px-3 text-[13px] gap-1.5",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = "secondary",
|
||||||
|
size = "sm",
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
{...rest}
|
||||||
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useId, type ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared control styling. Full-width forms mean the helper text that used to
|
||||||
|
* hide inside 27 hover-only tooltips can just be visible.
|
||||||
|
*/
|
||||||
|
export const inputClass =
|
||||||
|
"w-full px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
|
||||||
|
|
||||||
|
export const monoInputClass = `${inputClass} font-mono`;
|
||||||
|
|
||||||
|
export const selectClass =
|
||||||
|
"px-2.5 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] transition-colors";
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
label: string;
|
||||||
|
/** Visible helper text — the replacement for hover-only tooltips. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
children: (id: string) => ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Field({ label, hint, children, className = "" }: FieldProps) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="block text-[13px] font-medium text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{hint && (
|
||||||
|
<p className="mt-0.5 mb-1 text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={hint ? "" : "mt-1"}>{children(id)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label + helper text on the left, a control (usually a Toggle) on the right. */
|
||||||
|
export function SwitchRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: ReactNode;
|
||||||
|
control: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] font-medium text-[var(--text-primary)]">{label}</div>
|
||||||
|
{hint && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 pt-0.5">{control}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grouping card used by the Config tab (Workspace / Model / Access / Runtime). */
|
||||||
|
export function ConfigGroup({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
|
||||||
|
<header className="px-4 py-2.5 border-b border-[var(--border-color)]">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">{description}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="px-4 py-4 space-y-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
|
||||||
|
* needs that flushed manually.
|
||||||
|
*/
|
||||||
|
async function flushFocus() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Modal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes dialog semantics and an accessible name", async () => {
|
||||||
|
render(
|
||||||
|
<Modal title="Remove Project" onClose={vi.fn()}>
|
||||||
|
<p>body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole("dialog", { name: "Remove Project" });
|
||||||
|
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus into the dialog on open", async () => {
|
||||||
|
render(
|
||||||
|
<Modal title="Dialog" onClose={vi.fn()}>
|
||||||
|
<button>First</button>
|
||||||
|
<button>Second</button>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("traps Tab inside the dialog, wrapping at both ends", async () => {
|
||||||
|
render(
|
||||||
|
<Modal title="Dialog" onClose={vi.fn()} hideCloseButton>
|
||||||
|
<button>First</button>
|
||||||
|
<button>Last</button>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
|
||||||
|
const first = screen.getByRole("button", { name: "First" });
|
||||||
|
const last = screen.getByRole("button", { name: "Last" });
|
||||||
|
|
||||||
|
last.focus();
|
||||||
|
fireEvent.keyDown(document, { key: "Tab" });
|
||||||
|
expect(document.activeElement).toBe(first);
|
||||||
|
|
||||||
|
first.focus();
|
||||||
|
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||||
|
expect(document.activeElement).toBe(last);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to the trigger on unmount", async () => {
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
document.body.appendChild(trigger);
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<Modal title="Dialog" onClose={vi.fn()}>
|
||||||
|
<button>Inside</button>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
expect(document.activeElement).not.toBe(trigger);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
trigger.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape and on an overlay click", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<Modal title="Dialog" onClose={onClose}>
|
||||||
|
<p>body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The overlay is the portal root's only child.
|
||||||
|
const overlay = document.querySelector(".fixed.inset-0");
|
||||||
|
expect(overlay).not.toBeNull();
|
||||||
|
fireEvent.click(overlay!);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
expect(container).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores Escape and overlay clicks when not dismissible", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal title="Installing" onClose={onClose} dismissible={false}>
|
||||||
|
<p>body</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
await flushFocus();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
fireEvent.click(document.querySelector(".fixed.inset-0")!);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
"a[href]",
|
||||||
|
"area[href]",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"button:not([disabled])",
|
||||||
|
"iframe",
|
||||||
|
"object",
|
||||||
|
"embed",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
'[contenteditable="true"]',
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
function focusableWithin(root: HTMLElement): HTMLElement[] {
|
||||||
|
// Deliberately no `offsetParent` check: everything a dialog renders is
|
||||||
|
// visible, and `offsetParent` is unreliable inside fixed-position overlays.
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
|
(el) => !el.closest("[hidden]") && el.getAttribute("aria-hidden") !== "true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
/** Accessible name for the dialog. Rendered as the header unless `hideTitle`. */
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional sticky footer row (buttons live here). */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Optional sub-header description, wired to `aria-describedby`. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Tailwind width class for the dialog panel. */
|
||||||
|
widthClassName?: string;
|
||||||
|
/** When false, Escape / overlay click / the ✕ button do not close. */
|
||||||
|
dismissible?: boolean;
|
||||||
|
/** Hide the ✕ in the header (the footer usually carries a Close button). */
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
/** Focused on mount; falls back to the first focusable child. */
|
||||||
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Applied to the scrollable body wrapper. */
|
||||||
|
bodyClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one modal primitive. Every dialog in the app renders through this so
|
||||||
|
* `role="dialog"`, `aria-modal`, a focus trap, focus restore, Escape and
|
||||||
|
* click-outside are implemented once instead of twelve times.
|
||||||
|
*/
|
||||||
|
export default function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
description,
|
||||||
|
widthClassName = "w-[32rem]",
|
||||||
|
dismissible = true,
|
||||||
|
hideCloseButton = false,
|
||||||
|
initialFocusRef,
|
||||||
|
bodyClassName = "",
|
||||||
|
}: ModalProps) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descId = useId();
|
||||||
|
|
||||||
|
// Remember what had focus, move focus inside, restore on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (panel) {
|
||||||
|
const target =
|
||||||
|
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
|
||||||
|
// Defer so the panel is laid out (offsetParent) before we query it.
|
||||||
|
requestAnimationFrame(() => target.focus?.());
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
restoreFocusRef.current?.focus?.();
|
||||||
|
};
|
||||||
|
// Mount/unmount only — re-running would steal focus mid-interaction.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Escape closes; Tab is trapped inside the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && dismissible) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const items = focusableWithin(panel);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (!active || !panel.contains(active)) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && active === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
}, [dismissible, onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (dismissible && e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[dismissible, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={description ? descId : undefined}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`flex flex-col max-h-[85vh] ${widthClassName} max-w-full bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]`}
|
||||||
|
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 px-5 py-3 border-b border-[var(--border-color)] flex-shrink-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id={titleId} className="text-sm font-semibold text-[var(--text-primary)]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p id={descId} className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!hideCloseButton && dismissible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
|
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex-1 min-h-0 overflow-y-auto px-5 py-4 ${bodyClassName}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--border-color)] flex-shrink-0">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface OverflowItem {
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: OverflowItem[];
|
||||||
|
label?: string;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `⋯` menu that keeps destructive actions out of the main button row. */
|
||||||
|
export default function OverflowMenu({
|
||||||
|
items,
|
||||||
|
label = "More actions",
|
||||||
|
align = "right",
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative inline-block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="inline-flex items-center justify-center h-6 w-7 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="leading-none">⋯</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className={`absolute z-40 mt-1 min-w-[11rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] ${
|
||||||
|
align === "right" ? "right-0" : "left-0"
|
||||||
|
}`}
|
||||||
|
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
item.onSelect();
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent hover:bg-[var(--bg-tertiary)] ${
|
||||||
|
item.danger ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { SaveState } from "../../hooks/useSaveState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible outcome for save-on-blur. Config writes used to fail silently into
|
||||||
|
* `console.error`, which is silent data loss.
|
||||||
|
*/
|
||||||
|
export default function SaveIndicator({ state }: { state: SaveState }) {
|
||||||
|
if (state.status === "idle") return null;
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
saving: { text: "Saving…", color: "var(--text-secondary)" },
|
||||||
|
saved: { text: "Saved ✓", color: "var(--success)" },
|
||||||
|
failed: { text: "Save failed ✕", color: "var(--error)" },
|
||||||
|
} as const;
|
||||||
|
const tone = map[state.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: tone.color }}
|
||||||
|
title={state.error ?? undefined}
|
||||||
|
>
|
||||||
|
{tone.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
export interface Segment<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
/** Visible helper text under the control when this segment is selected. */
|
||||||
|
hint?: string;
|
||||||
|
/** Paint this segment with the caution treatment when selected. */
|
||||||
|
caution?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
/** Accessible group name. */
|
||||||
|
label: string;
|
||||||
|
segments: Segment<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roving-tabindex radio group rendered as a segmented control.
|
||||||
|
* Arrow keys move between segments; only the selected one is tabbable.
|
||||||
|
*/
|
||||||
|
export default function SegmentedControl<T extends string>({
|
||||||
|
label,
|
||||||
|
segments,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}: Props<T>) {
|
||||||
|
const groupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const move = (delta: number) => {
|
||||||
|
const index = segments.findIndex((s) => s.value === value);
|
||||||
|
const next = segments[(index + delta + segments.length) % segments.length];
|
||||||
|
if (!next) return;
|
||||||
|
onChange(next.value);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
groupRef.current
|
||||||
|
?.querySelector<HTMLElement>(`[data-segment="${next.value}"]`)
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={groupRef}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={label}
|
||||||
|
className={`inline-flex p-0.5 gap-0.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] ${className}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
move(1);
|
||||||
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
move(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{segments.map((segment) => {
|
||||||
|
const selected = segment.value === value;
|
||||||
|
let cls =
|
||||||
|
"text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]";
|
||||||
|
if (selected) {
|
||||||
|
cls = segment.caution
|
||||||
|
? "bg-[var(--warning-emphasis)] text-white"
|
||||||
|
: "bg-[var(--accent-emphasis)] text-white";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={segment.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
data-segment={segment.value}
|
||||||
|
aria-checked={selected}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(segment.value)}
|
||||||
|
className={`h-6 px-2.5 text-xs font-medium rounded-[4px] transition-colors disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent ${cls}`}
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ProjectStatus } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status is never encoded by hue alone: every tone carries a distinct glyph
|
||||||
|
* shape, and (unless `iconOnly`) a word.
|
||||||
|
*/
|
||||||
|
export type StatusTone =
|
||||||
|
| "running"
|
||||||
|
| "stopped"
|
||||||
|
| "busy"
|
||||||
|
| "error"
|
||||||
|
| "unknown"
|
||||||
|
| "ok"
|
||||||
|
| "off";
|
||||||
|
|
||||||
|
interface ToneStyle {
|
||||||
|
glyph: string;
|
||||||
|
color: string;
|
||||||
|
pulse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONES: Record<StatusTone, ToneStyle> = {
|
||||||
|
running: { glyph: "●", color: "var(--success)" },
|
||||||
|
ok: { glyph: "●", color: "var(--success)" },
|
||||||
|
stopped: { glyph: "○", color: "var(--text-secondary)" },
|
||||||
|
off: { glyph: "○", color: "var(--text-secondary)" },
|
||||||
|
busy: { glyph: "◐", color: "var(--warning)", pulse: true },
|
||||||
|
error: { glyph: "▲", color: "var(--error)" },
|
||||||
|
// Still being checked — distinct from "unavailable", and it pulses.
|
||||||
|
unknown: { glyph: "◌", color: "var(--text-disabled)", pulse: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_STATUS_TONE: Record<ProjectStatus, StatusTone> = {
|
||||||
|
running: "running",
|
||||||
|
stopped: "stopped",
|
||||||
|
starting: "busy",
|
||||||
|
stopping: "busy",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_STATUS_LABEL: Record<ProjectStatus, string> = {
|
||||||
|
running: "Running",
|
||||||
|
stopped: "Stopped",
|
||||||
|
starting: "Starting",
|
||||||
|
stopping: "Stopping",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tone: StatusTone;
|
||||||
|
label: string;
|
||||||
|
/** Render the glyph only; `label` still ships as accessible text. */
|
||||||
|
iconOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusIndicator({
|
||||||
|
tone,
|
||||||
|
label,
|
||||||
|
iconOnly = false,
|
||||||
|
className = "",
|
||||||
|
title,
|
||||||
|
}: Props) {
|
||||||
|
const style = TONES[tone];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 whitespace-nowrap ${className}`}
|
||||||
|
title={title ?? label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`leading-none text-[10px] ${style.pulse ? "animate-status-pulse" : ""}`}
|
||||||
|
style={{ color: style.color }}
|
||||||
|
>
|
||||||
|
{style.glyph}
|
||||||
|
</span>
|
||||||
|
{iconOnly ? (
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: style.color }}>{label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status pill for a project, derived from `ProjectStatus`. */
|
||||||
|
export function ProjectStatusIndicator({
|
||||||
|
status,
|
||||||
|
iconOnly,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
status: ProjectStatus;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<StatusIndicator
|
||||||
|
tone={PROJECT_STATUS_TONE[status]}
|
||||||
|
label={PROJECT_STATUS_LABEL[status]}
|
||||||
|
iconOnly={iconOnly}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useAppState, type Toast } from "../../store/appState";
|
||||||
|
|
||||||
|
const TONE: Record<Toast["kind"], { border: string; bg: string; fg: string; glyph: string }> = {
|
||||||
|
error: {
|
||||||
|
border: "var(--error)",
|
||||||
|
bg: "var(--error-muted)",
|
||||||
|
fg: "var(--error)",
|
||||||
|
glyph: "▲",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
border: "var(--success)",
|
||||||
|
bg: "var(--success-muted)",
|
||||||
|
fg: "var(--success)",
|
||||||
|
glyph: "✓",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
border: "var(--border-color)",
|
||||||
|
bg: "var(--accent-muted)",
|
||||||
|
fg: "var(--accent)",
|
||||||
|
glyph: "●",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const tone = TONE[toast.kind];
|
||||||
|
|
||||||
|
// Errors stay until dismissed; transient confirmations time out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.kind === "error") return;
|
||||||
|
const timer = setTimeout(onDismiss, 6000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toast.kind, onDismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-toast-in flex items-start gap-2 w-[24rem] max-w-[calc(100vw-2rem)] px-3 py-2 rounded-[var(--radius-panel)] border text-xs"
|
||||||
|
style={{
|
||||||
|
borderColor: tone.border,
|
||||||
|
background: `color-mix(in srgb, var(--bg-overlay) 88%, ${tone.bg})`,
|
||||||
|
boxShadow: "var(--shadow-overlay)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="mt-[1px] leading-none" style={{ color: tone.fg }}>
|
||||||
|
{tone.glyph}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
|
||||||
|
{toast.detail && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="mt-1 text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? "Hide details" : "Details"}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
|
||||||
|
{toast.detail}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom-right stack. Errors get a home here instead of a 12px card line. */
|
||||||
|
export default function ToastHost() {
|
||||||
|
const { toasts, dismissToast } = useAppState(
|
||||||
|
useShallow((s) => ({ toasts: s.toasts, dismissToast: s.dismissToast })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 items-end"
|
||||||
|
role="region"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastCard
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onDismiss={() => dismissToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
interface Props {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Accessible name — required, since the visual label lives outside. */
|
||||||
|
label: string;
|
||||||
|
/** Paint the "on" state as caution rather than success. */
|
||||||
|
tone?: "success" | "caution";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ON/OFF switch. The old version put white text on `--success` (~2.1:1, the
|
||||||
|
* worst contrast in the app); the on-state now uses a tinted background with
|
||||||
|
* the token colour as *foreground*.
|
||||||
|
*/
|
||||||
|
export default function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
tone = "success",
|
||||||
|
}: Props) {
|
||||||
|
const onStyle =
|
||||||
|
tone === "caution"
|
||||||
|
? "bg-[var(--warning-muted)] border-[var(--warning)] text-[var(--warning)]"
|
||||||
|
: "bg-[var(--success-muted)] border-[var(--success)] text-[var(--success)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`inline-flex items-center justify-center h-6 min-w-[3rem] px-2 text-xs font-semibold rounded-[var(--radius-control)] border transition-colors disabled:cursor-not-allowed disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:bg-[var(--bg-primary)] ${
|
||||||
|
checked
|
||||||
|
? onStyle
|
||||||
|
: "bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checked ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,8 +51,11 @@ export default function Tooltip({ text, children }: TooltipProps) {
|
|||||||
>
|
>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[10px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||||
aria-label="Help"
|
aria-label="Help"
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => setVisible(true)}
|
||||||
|
onBlur={() => setVisible(false)}
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single app-wide subscription to `container-progress`. Messages land in the
|
||||||
|
* store keyed by project id so sidebar rows and Project Home can both show
|
||||||
|
* inline progress — no blocking modal. The action that started the operation
|
||||||
|
* clears the line when it settles (see `useProjectActions`).
|
||||||
|
*/
|
||||||
|
export function useContainerProgress() {
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<{ project_id: string; message: string }>(
|
||||||
|
"container-progress",
|
||||||
|
(event) => {
|
||||||
|
useAppState
|
||||||
|
.getState()
|
||||||
|
.setContainerProgress(event.payload.project_id, event.payload.message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f()).catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
||||||
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-level shortcuts. Registered on `document` in the *capture* phase so they
|
||||||
|
* win over xterm.js, which would otherwise forward them to the shell inside
|
||||||
|
* the container.
|
||||||
|
*
|
||||||
|
* Ctrl+T new Claude terminal for the current project
|
||||||
|
* Ctrl+Shift+W close the active tab
|
||||||
|
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
|
||||||
|
* Ctrl+1..9 jump to the nth tab
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts() {
|
||||||
|
const { open: openTerminal, close: closeTerminal } = useTerminal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
|
||||||
|
const state = useAppState.getState();
|
||||||
|
|
||||||
|
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
if (state.tabOrder.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
state.cycleTab(e.shiftKey ? -1 : 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+W — close the active tab.
|
||||||
|
//
|
||||||
|
// Deliberately NOT plain Ctrl+W: that is readline's `kill-word` (delete
|
||||||
|
// the previous word), used constantly inside the terminal that is this
|
||||||
|
// app's centerpiece. Swallowing it globally would break word-erase in
|
||||||
|
// every shell and in Claude Code's own prompt.
|
||||||
|
if (e.shiftKey && (e.key === "w" || e.key === "W")) {
|
||||||
|
const key = state.activeTabKey;
|
||||||
|
if (!key) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isTerminalTab(key)) {
|
||||||
|
closeTerminal(tabKeyId(key)).catch((err) =>
|
||||||
|
console.error("Failed to close terminal:", err),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state.closeHomeTab(tabKeyId(key));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) return;
|
||||||
|
|
||||||
|
// Ctrl+1..9 — jump to tab
|
||||||
|
if (/^[1-9]$/.test(e.key)) {
|
||||||
|
const index = Number(e.key) - 1;
|
||||||
|
if (index >= state.tabOrder.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
state.focusTabIndex(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+T — new Claude terminal for the current project
|
||||||
|
if (e.key === "t" || e.key === "T") {
|
||||||
|
// Prefer the project owning the focused tab, then the sidebar selection.
|
||||||
|
const projectId =
|
||||||
|
state.sessions.find((s) => s.id === state.activeSessionId)?.projectId ??
|
||||||
|
state.selectedProjectId ??
|
||||||
|
null;
|
||||||
|
const project = state.projects.find((p) => p.id === projectId);
|
||||||
|
if (!project || project.status !== "running") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openTerminal(project.id, project.name).catch((err) => {
|
||||||
|
state.pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not open a terminal for “${project.name}”`,
|
||||||
|
detail: String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
}, [openTerminal, closeTerminal]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { Project } from "../lib/types";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import { useProjects } from "./useProjects";
|
||||||
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle + terminal actions for one project, shared by the sidebar row and
|
||||||
|
* Project Home so both surfaces behave identically. Failures raise a toast
|
||||||
|
* (with expandable detail) rather than a blocking modal or a 12px card line.
|
||||||
|
*/
|
||||||
|
export function useProjectActions(project: Project) {
|
||||||
|
const { start, stop, rebuild } = useProjects();
|
||||||
|
const { open: openTerminal, sendInput } = useTerminal();
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
|
|
||||||
|
const fail = useCallback(
|
||||||
|
(message: string, error: unknown) => {
|
||||||
|
pushToast({ kind: "error", message, detail: String(error) });
|
||||||
|
},
|
||||||
|
[pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (label: string, fn: () => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setContainerProgress(project.id, null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
fail(`${label} failed for “${project.name}”`, e);
|
||||||
|
} finally {
|
||||||
|
setContainerProgress(project.id, null);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fail, project.id, project.name, setContainerProgress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStart = useCallback(
|
||||||
|
() => run("Start", () => start(project.id)),
|
||||||
|
[run, start, project.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStop = useCallback(
|
||||||
|
() => run("Stop", () => stop(project.id)),
|
||||||
|
[run, stop, project.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReset = useCallback(
|
||||||
|
() => run("Reset", () => rebuild(project.id)),
|
||||||
|
[run, rebuild, project.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openClaudeTerminal = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
return await openTerminal(project.id, project.name);
|
||||||
|
} catch (e) {
|
||||||
|
fail(`Could not open a Claude terminal for “${project.name}”`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [openTerminal, project.id, project.name, fail]);
|
||||||
|
|
||||||
|
const openShell = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
return await openTerminal(project.id, project.name, "bash");
|
||||||
|
} catch (e) {
|
||||||
|
fail(`Could not open a shell for “${project.name}”`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [openTerminal, project.id, project.name, fail]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a shell tab and type `command` into it. Used for [Resume] and for the
|
||||||
|
* capability drawer's "Manage in terminal" — the backend has no
|
||||||
|
* run-a-command entry point, so we drive the shell we just opened.
|
||||||
|
*/
|
||||||
|
const openTerminalWithCommand = useCallback(
|
||||||
|
async (command: string, sessionLabel?: string) => {
|
||||||
|
try {
|
||||||
|
const sessionId = await openTerminal(
|
||||||
|
project.id,
|
||||||
|
project.name,
|
||||||
|
"bash",
|
||||||
|
sessionLabel,
|
||||||
|
);
|
||||||
|
// Give the login shell a moment to draw its prompt before typing.
|
||||||
|
setTimeout(() => {
|
||||||
|
sendInput(sessionId, `${command}\n`).catch((e) =>
|
||||||
|
fail("Could not send the command to the terminal", e),
|
||||||
|
);
|
||||||
|
}, 700);
|
||||||
|
return sessionId;
|
||||||
|
} catch (e) {
|
||||||
|
fail(`Could not open a terminal for “${project.name}”`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openTerminal, sendInput, project.id, project.name, fail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackup = useCallback(async () => {
|
||||||
|
if (!project.container_id) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Start the project at least once before backing up.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||||
|
const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
||||||
|
try {
|
||||||
|
const hostPath = await save({
|
||||||
|
defaultPath: `${safeName}-backup-${stamp}.tar.gz`,
|
||||||
|
filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }],
|
||||||
|
});
|
||||||
|
if (!hostPath) return;
|
||||||
|
setBackingUp(true);
|
||||||
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||||
|
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||||
|
pushToast({
|
||||||
|
kind: "success",
|
||||||
|
message: `Backup saved (${mb} MB).`,
|
||||||
|
detail:
|
||||||
|
"Includes Claude config — may contain API keys. Keep the archive private.",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
fail("Backup failed", e);
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
}, [project.container_id, project.id, project.name, pushToast, fail]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
backingUp,
|
||||||
|
handleStart,
|
||||||
|
handleStop,
|
||||||
|
handleReset,
|
||||||
|
handleBackup,
|
||||||
|
openClaudeTerminal,
|
||||||
|
openShell,
|
||||||
|
openTerminalWithCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { Project } from "../lib/types";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
|
export type SaveStatus = "idle" | "saving" | "saved" | "failed";
|
||||||
|
|
||||||
|
export interface SaveState {
|
||||||
|
status: SaveStatus;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save-on-blur with a *visible* outcome. Previously every config write
|
||||||
|
* swallowed its failure into `console.error`, which is silent data loss.
|
||||||
|
*/
|
||||||
|
export function useProjectSave(project: Project) {
|
||||||
|
const { update } = useProjects();
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [state, setState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (patch: Partial<Project>) => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setState({ status: "saving", error: null });
|
||||||
|
try {
|
||||||
|
await update({ ...project, ...patch });
|
||||||
|
setState({ status: "saved", error: null });
|
||||||
|
resetTimer.current = setTimeout(
|
||||||
|
() => setState({ status: "idle", error: null }),
|
||||||
|
2500,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const message = String(e);
|
||||||
|
setState({ status: "failed", error: message });
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `Could not save settings for “${project.name}”`,
|
||||||
|
detail: message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[project, update, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { save, saveState: state };
|
||||||
|
}
|
||||||
+44
-2
@@ -4,17 +4,59 @@
|
|||||||
--bg-primary: #0d1117;
|
--bg-primary: #0d1117;
|
||||||
--bg-secondary: #161b22;
|
--bg-secondary: #161b22;
|
||||||
--bg-tertiary: #21262d;
|
--bg-tertiary: #21262d;
|
||||||
|
/* Elevation: modals/menus sit above --bg-secondary surfaces. */
|
||||||
|
--bg-overlay: #1c2128;
|
||||||
|
--shadow-overlay: 0 16px 48px rgba(1, 4, 9, 0.85);
|
||||||
--border-color: #30363d;
|
--border-color: #30363d;
|
||||||
--text-primary: #e6edf3;
|
--text-primary: #e6edf3;
|
||||||
--text-secondary: #8b949e;
|
--text-secondary: #8b949e;
|
||||||
|
/* Dedicated disabled ink — `opacity-50` on --text-secondary drops to ~2.4:1. */
|
||||||
|
--text-disabled: #6e7681;
|
||||||
|
/* --accent is the foreground/link accent (fails AA behind white text);
|
||||||
|
--accent-emphasis is the fill for buttons carrying white text (~4.7:1). */
|
||||||
--accent: #58a6ff;
|
--accent: #58a6ff;
|
||||||
--accent-hover: #79c0ff;
|
--accent-hover: #79c0ff;
|
||||||
|
--accent-emphasis: #1f6feb;
|
||||||
|
--accent-emphasis-hover: #388bfd;
|
||||||
--success: #3fb950;
|
--success: #3fb950;
|
||||||
|
--success-emphasis: #238636;
|
||||||
--warning: #d29922;
|
--warning: #d29922;
|
||||||
|
--warning-emphasis: #9e6a03;
|
||||||
--error: #f85149;
|
--error: #f85149;
|
||||||
|
--error-emphasis: #b62324;
|
||||||
|
/* Muted tints, replacing hand-rolled `bg-yellow-500/20` style leaks. */
|
||||||
|
--accent-muted: rgba(56, 139, 253, 0.15);
|
||||||
|
--success-muted: rgba(63, 185, 80, 0.15);
|
||||||
|
--warning-muted: rgba(210, 153, 34, 0.15);
|
||||||
|
--error-muted: rgba(248, 81, 73, 0.12);
|
||||||
|
--focus-ring: #58a6ff;
|
||||||
|
/* Two radii only: controls and panels. */
|
||||||
|
--radius-control: 6px;
|
||||||
|
--radius-panel: 8px;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One visible focus indicator for every interactive element. Components must
|
||||||
|
not strip this with `focus:outline-none`. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Still-unknown states pulse; failed states are painted, not animated. */
|
||||||
|
@keyframes status-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.animate-status-pulse { animation: status-pulse 1.4s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-toast-in { animation: toast-in 0.18s ease-out; }
|
||||||
|
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -131,7 +173,7 @@ body {
|
|||||||
.help-content .help-inline-code {
|
.help-content .help-inline-code {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-control);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -165,7 +207,7 @@ body {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 var(--radius-control) var(--radius-control) 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, SchedulerNotification } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, SchedulerNotification, AuthBridgeStatus } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -133,3 +133,30 @@ export const getSchedulerNotifications = (projectId: string) =>
|
|||||||
invoke<SchedulerNotification[]>("get_scheduler_notifications", { projectId });
|
invoke<SchedulerNotification[]>("get_scheduler_notifications", { projectId });
|
||||||
export const clearSchedulerNotifications = (projectId: string) =>
|
export const clearSchedulerNotifications = (projectId: string) =>
|
||||||
invoke<void>("clear_scheduler_notifications", { projectId });
|
invoke<void>("clear_scheduler_notifications", { projectId });
|
||||||
|
|
||||||
|
// Auth bridge — mirrors container loopback listeners onto host loopback so
|
||||||
|
// browser OAuth logins started inside the container can complete.
|
||||||
|
export const setAuthBridgeEnabled = (projectId: string, enabled: boolean) =>
|
||||||
|
invoke<AuthBridgeStatus>("set_auth_bridge_enabled", { projectId, enabled });
|
||||||
|
export const getAuthBridgeStatus = (projectId: string) =>
|
||||||
|
invoke<AuthBridgeStatus>("get_auth_bridge_status", { projectId });
|
||||||
|
|
||||||
|
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||||
|
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||||
|
// lives in the OS keychain and is injected as a container env var.
|
||||||
|
//
|
||||||
|
// `acquireClaudeToken` borrows the given project's running container to run the
|
||||||
|
// login (temporarily enabling its auth bridge), and streams progress on the
|
||||||
|
// `claude-token-progress` and `claude-token-output` events. It resolves only
|
||||||
|
// once the whole flow finishes, so call it without awaiting the UI on it.
|
||||||
|
//
|
||||||
|
// Partway through, `claude setup-token` prints a sign-in URL and then waits at
|
||||||
|
// a "Paste code here" prompt: the user signs in, copies the code shown by
|
||||||
|
// Anthropic, and it is delivered with `submitClaudeTokenCode`. One flow at a
|
||||||
|
// time — a second `acquireClaudeToken` call rejects while one is in progress.
|
||||||
|
export const acquireClaudeToken = (projectId: string) =>
|
||||||
|
invoke<void>("acquire_claude_token", { projectId });
|
||||||
|
export const submitClaudeTokenCode = (code: string) =>
|
||||||
|
invoke<void>("submit_claude_token_code", { code });
|
||||||
|
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||||
|
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export interface Project {
|
|||||||
allow_docker_access: boolean;
|
allow_docker_access: boolean;
|
||||||
sandbox_mode_enabled: boolean;
|
sandbox_mode_enabled: boolean;
|
||||||
mission_control_enabled: boolean;
|
mission_control_enabled: boolean;
|
||||||
|
/** Mirror container loopback listeners onto host loopback so in-container
|
||||||
|
* browser OAuth logins can complete. Host-side only — no container recreate. */
|
||||||
|
auth_bridge_enabled: boolean;
|
||||||
|
/** Use the shared long-lived Claude Code token (from `claude setup-token`,
|
||||||
|
* held in the OS keychain) instead of this project's own `claude login`.
|
||||||
|
* Defaults to true; only applies when `backend` is "anthropic" and a token
|
||||||
|
* has actually been stored. */
|
||||||
|
use_shared_auth_token: boolean;
|
||||||
/** Legacy binary permission flag; superseded by `permission_mode`, kept for
|
/** Legacy binary permission flag; superseded by `permission_mode`, kept for
|
||||||
* existing projects.json data. */
|
* existing projects.json data. */
|
||||||
full_permissions: boolean;
|
full_permissions: boolean;
|
||||||
@@ -293,3 +301,51 @@ export interface SchedulerNotification {
|
|||||||
body: string;
|
body: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Which loopback family the container-side listener was found on.
|
||||||
|
* Mirrors Rust `PortFamily` (serde lowercase). */
|
||||||
|
export type AuthBridgePortFamily = "v4" | "v6" | "dual";
|
||||||
|
|
||||||
|
/** A container loopback port currently mirrored onto the host's loopback. */
|
||||||
|
export interface BridgedPort {
|
||||||
|
port: number;
|
||||||
|
family: AuthBridgePortFamily;
|
||||||
|
/** RFC 3339 timestamp of when the host listener was bound. */
|
||||||
|
bridged_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A discovered loopback listener that could not be bridged (host port taken). */
|
||||||
|
export interface PortConflict {
|
||||||
|
port: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthBridgeStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
active_ports: BridgedPort[];
|
||||||
|
conflicts: PortConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `auth-bridge-changed` event, emitted whenever the bridged
|
||||||
|
* port set or the conflict set changes for a project. */
|
||||||
|
export interface AuthBridgeChangedEvent {
|
||||||
|
project_id: string;
|
||||||
|
status: AuthBridgeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `claude-token-progress` event: milestones during
|
||||||
|
* `acquire_claude_token`. Never contains the token. */
|
||||||
|
export interface ClaudeTokenProgressEvent {
|
||||||
|
project_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `claude-token-output` event: output from
|
||||||
|
* `claude setup-token`, so the UI can show the URL the user must visit.
|
||||||
|
* Credentials are redacted backend-side before the event is emitted. */
|
||||||
|
export interface ClaudeTokenOutputEvent {
|
||||||
|
project_id: string;
|
||||||
|
chunk: string;
|
||||||
|
}
|
||||||
|
|||||||
+209
-19
@@ -19,6 +19,38 @@ function persistSidebarCollapsed(value: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
||||||
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
||||||
|
* hold both kinds.
|
||||||
|
*/
|
||||||
|
export const terminalTabKey = (sessionId: string) => `term:${sessionId}`;
|
||||||
|
export const homeTabKey = (projectId: string) => `home:${projectId}`;
|
||||||
|
export const isTerminalTab = (key: string) => key.startsWith("term:");
|
||||||
|
export const isHomeTab = (key: string) => key.startsWith("home:");
|
||||||
|
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
||||||
|
|
||||||
|
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
||||||
|
function activation(activeTabKey: string | null) {
|
||||||
|
return {
|
||||||
|
activeTabKey,
|
||||||
|
activeSessionId:
|
||||||
|
activeTabKey && isTerminalTab(activeTabKey) ? tabKeyId(activeTabKey) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToastKind = "error" | "success" | "info";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
message: string;
|
||||||
|
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastCounter = 0;
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -35,6 +67,26 @@ interface AppState {
|
|||||||
removeSession: (id: string) => void;
|
removeSession: (id: string) => void;
|
||||||
setActiveSession: (id: string | null) => void;
|
setActiveSession: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Main-area tab strip (terminals + Project Home views)
|
||||||
|
tabOrder: string[];
|
||||||
|
activeTabKey: string | null;
|
||||||
|
openProjectHome: (projectId: string) => void;
|
||||||
|
closeHomeTab: (projectId: string) => void;
|
||||||
|
setActiveTabKey: (key: string) => void;
|
||||||
|
cycleTab: (delta: number) => void;
|
||||||
|
focusTabIndex: (index: number) => void;
|
||||||
|
|
||||||
|
// Inline container progress, replacing the blocking progress modal.
|
||||||
|
containerProgress: Record<string, string>;
|
||||||
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
||||||
|
/** Wall-clock ms when a project was observed transitioning to "running". */
|
||||||
|
runningSince: Record<string, number>;
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
toasts: Toast[];
|
||||||
|
pushToast: (toast: Omit<Toast, "id">) => string;
|
||||||
|
dismissToast: (id: string) => void;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
@@ -72,45 +124,183 @@ interface AppState {
|
|||||||
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track running-since transitions so Overview can show an uptime. */
|
||||||
|
function trackRunning(
|
||||||
|
previous: Project[],
|
||||||
|
next: Project[],
|
||||||
|
runningSince: Record<string, number>,
|
||||||
|
): Record<string, number> {
|
||||||
|
let changed = false;
|
||||||
|
const result = { ...runningSince };
|
||||||
|
const now = Date.now();
|
||||||
|
for (const project of next) {
|
||||||
|
const before = previous.find((p) => p.id === project.id);
|
||||||
|
if (project.status === "running") {
|
||||||
|
if (result[project.id] === undefined) {
|
||||||
|
result[project.id] = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (before?.status === "running" || result[project.id] !== undefined) {
|
||||||
|
delete result[project.id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? result : runningSince;
|
||||||
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
// Projects
|
// Projects
|
||||||
projects: [],
|
projects: [],
|
||||||
selectedProjectId: null,
|
selectedProjectId: null,
|
||||||
setProjects: (projects) => set({ projects }),
|
setProjects: (projects) =>
|
||||||
|
set((state) => ({
|
||||||
|
projects,
|
||||||
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
||||||
|
})),
|
||||||
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
||||||
updateProjectInList: (project) =>
|
updateProjectInList: (project) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
projects: state.projects.map((p) =>
|
const projects = state.projects.map((p) =>
|
||||||
p.id === project.id ? project : p,
|
p.id === project.id ? project : p,
|
||||||
),
|
);
|
||||||
})),
|
return {
|
||||||
|
projects,
|
||||||
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
||||||
|
};
|
||||||
|
}),
|
||||||
removeProjectFromList: (id) =>
|
removeProjectFromList: (id) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const key = homeTabKey(id);
|
||||||
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
||||||
|
const activeTabKey =
|
||||||
|
state.activeTabKey === key
|
||||||
|
? (tabOrder[tabOrder.length - 1] ?? null)
|
||||||
|
: state.activeTabKey;
|
||||||
|
return {
|
||||||
projects: state.projects.filter((p) => p.id !== id),
|
projects: state.projects.filter((p) => p.id !== id),
|
||||||
selectedProjectId:
|
selectedProjectId:
|
||||||
state.selectedProjectId === id ? null : state.selectedProjectId,
|
state.selectedProjectId === id ? null : state.selectedProjectId,
|
||||||
})),
|
tabOrder,
|
||||||
|
...activation(activeTabKey),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// Terminal sessions
|
// Terminal sessions
|
||||||
sessions: [],
|
sessions: [],
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
addSession: (session) =>
|
addSession: (session) =>
|
||||||
set((state) => ({
|
|
||||||
sessions: [...state.sessions, session],
|
|
||||||
activeSessionId: session.id,
|
|
||||||
})),
|
|
||||||
removeSession: (id) =>
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const sessions = state.sessions.filter((s) => s.id !== id);
|
const key = terminalTabKey(session.id);
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions: [...state.sessions, session],
|
||||||
activeSessionId:
|
tabOrder: state.tabOrder.includes(key)
|
||||||
state.activeSessionId === id
|
? state.tabOrder
|
||||||
? (sessions[sessions.length - 1]?.id ?? null)
|
: [...state.tabOrder, key],
|
||||||
: state.activeSessionId,
|
...activation(key),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
removeSession: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const key = terminalTabKey(id);
|
||||||
|
const index = state.tabOrder.indexOf(key);
|
||||||
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
||||||
|
const activeTabKey =
|
||||||
|
state.activeTabKey === key
|
||||||
|
? (tabOrder[Math.min(Math.max(index, 0), tabOrder.length - 1)] ?? null)
|
||||||
|
: state.activeTabKey;
|
||||||
|
return {
|
||||||
|
sessions: state.sessions.filter((s) => s.id !== id),
|
||||||
|
tabOrder,
|
||||||
|
...activation(activeTabKey),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setActiveSession: (id) =>
|
||||||
|
set(() => activation(id === null ? null : terminalTabKey(id))),
|
||||||
|
|
||||||
|
// Main-area tabs
|
||||||
|
tabOrder: [],
|
||||||
|
activeTabKey: null,
|
||||||
|
openProjectHome: (projectId) =>
|
||||||
|
set((state) => {
|
||||||
|
const key = homeTabKey(projectId);
|
||||||
|
return {
|
||||||
|
selectedProjectId: projectId,
|
||||||
|
tabOrder: state.tabOrder.includes(key)
|
||||||
|
? state.tabOrder
|
||||||
|
: [...state.tabOrder, key],
|
||||||
|
...activation(key),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
closeHomeTab: (projectId) =>
|
||||||
|
set((state) => {
|
||||||
|
const key = homeTabKey(projectId);
|
||||||
|
const index = state.tabOrder.indexOf(key);
|
||||||
|
if (index === -1) return {};
|
||||||
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
||||||
|
const activeTabKey =
|
||||||
|
state.activeTabKey === key
|
||||||
|
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
|
||||||
|
: state.activeTabKey;
|
||||||
|
return { tabOrder, ...activation(activeTabKey) };
|
||||||
|
}),
|
||||||
|
setActiveTabKey: (key) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!state.tabOrder.includes(key)) return {};
|
||||||
|
const patch = activation(key);
|
||||||
|
return isHomeTab(key)
|
||||||
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
||||||
|
: patch;
|
||||||
|
}),
|
||||||
|
cycleTab: (delta) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.tabOrder.length === 0) return {};
|
||||||
|
const current = state.activeTabKey
|
||||||
|
? state.tabOrder.indexOf(state.activeTabKey)
|
||||||
|
: -1;
|
||||||
|
const next =
|
||||||
|
(current + delta + state.tabOrder.length) % state.tabOrder.length;
|
||||||
|
const key = state.tabOrder[next];
|
||||||
|
const patch = activation(key);
|
||||||
|
return isHomeTab(key)
|
||||||
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
||||||
|
: patch;
|
||||||
|
}),
|
||||||
|
focusTabIndex: (index) =>
|
||||||
|
set((state) => {
|
||||||
|
const key = state.tabOrder[index];
|
||||||
|
if (!key) return {};
|
||||||
|
const patch = activation(key);
|
||||||
|
return isHomeTab(key)
|
||||||
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
||||||
|
: patch;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Container progress
|
||||||
|
containerProgress: {},
|
||||||
|
setContainerProgress: (projectId, message) =>
|
||||||
|
set((state) => {
|
||||||
|
if (message === null) {
|
||||||
|
if (state.containerProgress[projectId] === undefined) return {};
|
||||||
|
const next = { ...state.containerProgress };
|
||||||
|
delete next[projectId];
|
||||||
|
return { containerProgress: next };
|
||||||
|
}
|
||||||
|
if (state.containerProgress[projectId] === message) return {};
|
||||||
|
return {
|
||||||
|
containerProgress: { ...state.containerProgress, [projectId]: message },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
runningSince: {},
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
toasts: [],
|
||||||
|
pushToast: (toast) => {
|
||||||
|
const id = `toast-${++toastCounter}`;
|
||||||
|
set((state) => ({ toasts: [...state.toasts, { ...toast, id }] }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
dismissToast: (id) =>
|
||||||
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user