Compare commits

..

2 Commits

Author SHA1 Message Date
df3d434877 Fix SSH keys, git config, and HTTPS token not applied on container restart
All checks were successful
Build App / build-linux (push) Successful in 2m26s
Build App / build-windows (push) Successful in 3m17s
Recreate the container when SSH key path, git name, git email, or git
HTTPS token change — not just when the docker socket toggle changes.
The claude config named volume persists across recreation so no data
is lost.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:31:27 -08:00
10 changed files with 86 additions and 37 deletions

View File

@@ -102,20 +102,16 @@ pub async fn start_project_container(
// Check for existing container
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Check if docker socket mount matches the current project setting.
// If the user toggled "Allow container spawning" after the container was
// created, we need to recreate the container for the mount change to take
// effect.
let has_socket = docker::container_has_docker_socket(&existing_id).await.unwrap_or(false);
if has_socket != project.allow_docker_access {
log::info!(
"Docker socket mismatch (container has_socket={}, project wants={}), recreating container",
has_socket, project.allow_docker_access
);
// Safe to remove and recreate: the claude config named volume is
// keyed by project ID (not container ID) so it persists across
// container recreation. Bind mounts (workspace, SSH, AWS) are
// host paths and are unaffected.
// Compare the running container's configuration (mounts, env vars)
// against the current project settings. If anything changed (SSH key
// path, git config, docker socket, etc.) we recreate the container.
// Safe to recreate: the claude config named volume is keyed by
// project ID (not container ID) so it persists across recreation.
let needs_recreation = docker::container_needs_recreation(&existing_id, &project)
.await
.unwrap_or(false);
if needs_recreation {
log::info!("Container config changed, recreating container for project {}", project.id);
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
let new_id = docker::create_container(

View File

@@ -288,25 +288,82 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e))
}
/// Check whether an existing container has docker socket mounted.
pub async fn container_has_docker_socket(container_id: &str) -> Result<bool, String> {
/// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ).
pub async fn container_needs_recreation(container_id: &str, project: &Project) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
.inspect_container(container_id, None)
.await
.map_err(|e| format!("Failed to inspect container: {}", e))?;
let has_socket = info
let mounts = info
.host_config
.and_then(|hc| hc.mounts)
.map(|mounts| {
mounts.iter().any(|m| {
m.target.as_deref() == Some("/var/run/docker.sock")
})
.as_ref()
.and_then(|hc| hc.mounts.as_ref());
// ── Docker socket mount ──────────────────────────────────────────────
let has_socket = mounts
.map(|m| {
m.iter()
.any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock"))
})
.unwrap_or(false);
if has_socket != project.allow_docker_access {
log::info!("Docker socket mismatch (container={}, project={})", has_socket, project.allow_docker_access);
return Ok(true);
}
Ok(has_socket)
// ── SSH key path mount ───────────────────────────────────────────────
let ssh_mount_source = mounts
.and_then(|m| {
m.iter()
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
})
.and_then(|mount| mount.source.as_deref());
let project_ssh = project.ssh_key_path.as_deref();
if ssh_mount_source != project_ssh {
log::info!(
"SSH key path mismatch (container={:?}, project={:?})",
ssh_mount_source,
project_ssh
);
return Ok(true);
}
// ── Git environment variables ────────────────────────────────────────
let env_vars = info
.config
.as_ref()
.and_then(|c| c.env.as_ref());
let get_env = |name: &str| -> Option<String> {
env_vars.and_then(|vars| {
vars.iter()
.find(|v| v.starts_with(&format!("{}=", name)))
.map(|v| v[name.len() + 1..].to_string())
})
};
let container_git_name = get_env("GIT_USER_NAME");
let container_git_email = get_env("GIT_USER_EMAIL");
let container_git_token = get_env("GIT_TOKEN");
if container_git_name.as_deref() != project.git_user_name.as_deref() {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
return Ok(true);
}
if container_git_email.as_deref() != project.git_user_email.as_deref() {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
return Ok(true);
}
if container_git_token.as_deref() != project.git_token.as_deref() {
log::info!("GIT_TOKEN mismatch");
return Ok(true);
}
Ok(false)
}
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {

View File

@@ -6,7 +6,7 @@ export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState();
return (
<div className="flex flex-col h-full w-64 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
{/* Nav tabs */}
<div className="flex border-b border-[var(--border-color)]">
<button
@@ -32,7 +32,7 @@ export default function Sidebar() {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto p-1">
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
</div>
</div>

View File

@@ -5,7 +5,7 @@ export default function StatusBar() {
const running = projects.filter((p) => p.status === "running").length;
return (
<div className="flex items-center h-6 px-3 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
<span>
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>

View File

@@ -6,10 +6,10 @@ export default function TopBar() {
return (
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex-1 overflow-x-auto">
<div className="flex-1 overflow-x-auto pl-2">
<TerminalTabs />
</div>
<div className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]">
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
</div>

View File

@@ -159,7 +159,7 @@ export default function ProjectCard({ project }: Props) {
</div>
{/* Action buttons */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-wrap">
{isStopped ? (
<>
<ActionButton onClick={handleStart} disabled={loading} label="Start" />

View File

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

View File

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

View File

@@ -170,7 +170,7 @@ export default function TerminalView({ sessionId, active }: Props) {
<div
ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "4px" }}
style={{ padding: "8px" }}
/>
);
}

View File

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