From 9d7dde6a6d6e08dd10b65ee746810adb901b1b18 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sun, 11 Jan 2026 19:40:19 +0000 Subject: [PATCH 01/70] chore(launcher): release v0.5.1 --- ushadow/launcher/package.json | 2 +- ushadow/launcher/src-tauri/Cargo.toml | 2 +- ushadow/launcher/src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ushadow/launcher/package.json b/ushadow/launcher/package.json index 5de75e69..5ed233a1 100644 --- a/ushadow/launcher/package.json +++ b/ushadow/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ushadow-launcher", - "version": "0.4.17", + "version": "0.5.1", "description": "Ushadow Desktop Launcher", "private": true, "type": "module", diff --git a/ushadow/launcher/src-tauri/Cargo.toml b/ushadow/launcher/src-tauri/Cargo.toml index 14b4706f..e01b5ec7 100644 --- a/ushadow/launcher/src-tauri/Cargo.toml +++ b/ushadow/launcher/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ushadow-launcher" -version = "0.4.17" +version = "0.5.1" description = "Ushadow Desktop Launcher" authors = ["Ushadow"] license = "MIT" diff --git a/ushadow/launcher/src-tauri/tauri.conf.json b/ushadow/launcher/src-tauri/tauri.conf.json index 770a573d..8add0751 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Ushadow", - "version": "0.4.17" + "version": "0.5.1" }, "tauri": { "allowlist": { From ccd165b36d3f2e1735d48288f09b6edc39feb4a7 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Tue, 13 Jan 2026 13:16:45 +0000 Subject: [PATCH 02/70] added tmux --- .workmux.yaml | 76 ++ ushadow/backend/.dockerignore | 46 + ushadow/backend/Dockerfile | 44 +- .../src-tauri/src/commands/discovery.rs | 29 +- .../src-tauri/src/commands/prerequisites.rs | 56 ++ .../launcher/src-tauri/src/commands/utils.rs | 1 - .../src-tauri/src/commands/worktree.rs | 861 +++++++++++++++++- ushadow/launcher/src-tauri/src/main.rs | 20 +- ushadow/launcher/src-tauri/src/models.rs | 38 + ushadow/launcher/src/App.tsx | 174 +++- .../src/components/EnvironmentsPanel.tsx | 320 ++++++- .../src/components/NewEnvironmentDialog.tsx | 299 +++--- .../src/components/TmuxManagerDialog.tsx | 245 +++++ ushadow/launcher/src/hooks/useTauri.ts | 46 + .../launcher/src/hooks/useTmuxMonitoring.ts | 100 ++ 15 files changed, 2123 insertions(+), 232 deletions(-) create mode 100644 .workmux.yaml create mode 100644 ushadow/backend/.dockerignore create mode 100644 ushadow/launcher/src/components/TmuxManagerDialog.tsx create mode 100644 ushadow/launcher/src/hooks/useTmuxMonitoring.ts diff --git a/.workmux.yaml b/.workmux.yaml new file mode 100644 index 00000000..17d84c34 --- /dev/null +++ b/.workmux.yaml @@ -0,0 +1,76 @@ +# Workmux Configuration for Ushadow Project +# This config optimizes git worktree + tmux workflow for parallel environment development + +# Main branch to merge into (auto-detected from git, but can override) +main_branch: "main" + +# Where worktrees are created (relative to project root or absolute path) +# Using parent directory pattern for cleaner organization +worktree_dir: "../" + +# Prefix for tmux window names (helps identify workmux windows) +window_prefix: "ushadow-" + +# Default merge strategy: merge | rebase | squash +# Rebase recommended for cleaner history +merge_strategy: "rebase" + +# How to name worktree directories: full (branch name) | basename (last component) +worktree_naming: "full" + +# Commands to run after worktree creation (before tmux window opens) +post_create: + # Setup VSCode colors for environment identification + - "uv run --with pyyaml python3 -c \"from setup.vscode_utils.colors import setup_colors_for_directory; from pathlib import Path; setup_colors_for_directory(Path('.'), '$(basename $(pwd))')\" || true" + # Generate secrets if needed + - "bash scripts/generate-secrets.sh || true" + +# Commands to run before merging (aborts merge if any fail) +pre_merge: + # Ensure tests pass before merging + # - "make test" # Uncomment when test suite is ready + +# Commands to run before removing worktree +pre_remove: + # Stop any running containers for this environment + - "ENV_NAME=$(basename $(pwd)) bash scripts/stop-env.sh || true" + +# File operations (applied after worktree creation) +files: + # Copy these files/patterns to each worktree (separate configs per env) + copy: + - ".env" # Each environment gets its own .env with unique ports + - ".vscode/settings.json" # VSCode colors set per environment + + # Symlink these files/patterns (shared across worktrees) + symlink: + - "node_modules" # Share node_modules to save disk space + - ".venv" # Share Python virtual environment + - "ushadow/frontend/node_modules" # Share frontend deps + +# Tmux pane layout (how the window is organized) +panes: + # Main pane - ready for commands or agent interaction + - command: "echo '🚀 Ushadow Environment: $(basename $(pwd))' && echo '' && echo 'Quick commands:' && echo ' ./dev.sh - Start in dev mode' && echo ' ./go.sh - Start in prod mode' && echo ' make test - Run tests' && echo ' code . - Open in VSCode' && echo '' && $SHELL" + focus: true + split: horizontal + size: 75% + +# Agent status icons (shown in tmux status bar) +status_icons: + working: '🤖' # Agent actively processing + waiting: '💬' # Agent waiting for input + done: '✅' # Agent task completed + error: '❌' # Agent encountered error + +# Auto-format tmux status line to show agent status +status_format: true + +# LLM-based branch name generation (when using --auto-name flag) +auto_name: + # Use fast, cheap model for branch name generation + model: 'gemini-2.0-flash-lite' + system_prompt: | + Generate a short, descriptive git branch name (2-4 words, kebab-case). + Focus on the feature/fix being worked on. + Examples: "fix-auth-bug", "add-tailscale-wizard", "refactor-docker-setup" diff --git a/ushadow/backend/.dockerignore b/ushadow/backend/.dockerignore new file mode 100644 index 00000000..92529343 --- /dev/null +++ b/ushadow/backend/.dockerignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation +*.md +docs/ + +# CI/CD +.github/ +.gitlab-ci.yml + +# Local development +.env.local +*.log diff --git a/ushadow/backend/Dockerfile b/ushadow/backend/Dockerfile index add849eb..1f154999 100644 --- a/ushadow/backend/Dockerfile +++ b/ushadow/backend/Dockerfile @@ -1,35 +1,59 @@ # ushadow Backend Dockerfile # FastAPI-based orchestration API -FROM python:3.12-slim +# ============================================================================ +# Builder Stage - Install dependencies with build tools +# ============================================================================ +FROM python:3.12-slim AS builder WORKDIR /app # Install uv for fast Python package management COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ -# Install system dependencies, build tools, Docker CLI, and Tailscale +# Install build dependencies for Python packages (gcc, python3-dev, libyaml-dev) RUN apt-get update && apt-get install -y \ - curl \ - ca-certificates \ - gnupg \ gcc \ python3-dev \ libyaml-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency files first for better layer caching +COPY pyproject.toml uv.lock ./ + +# Install dependencies with uv (frozen from lockfile) +# This creates a .venv directory with all dependencies +RUN uv sync --frozen --no-dev --no-install-project + +# ============================================================================ +# Runtime Stage - Minimal image with only runtime dependencies +# ============================================================================ +FROM python:3.12-slim + +WORKDIR /app + +# Install uv for running the app +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +# Install only runtime dependencies (curl for healthcheck, docker-cli for volume cleanup, tailscale) +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + gnupg \ && install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ && chmod a+r /etc/apt/keyrings/docker.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ && apt-get update \ - && apt-get install -y docker-ce-cli docker-compose-plugin \ + && apt-get install -y docker-ce-cli \ && curl -fsSL https://tailscale.com/install.sh | sh \ && rm -rf /var/lib/apt/lists/* -# Copy dependency files first for better layer caching -COPY pyproject.toml uv.lock ./ +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv -# Install dependencies with uv (frozen from lockfile) -RUN uv sync --frozen --no-dev --no-install-project +# Copy dependency files (needed for uv run) +COPY pyproject.toml uv.lock ./ # Copy application code COPY . . diff --git a/ushadow/launcher/src-tauri/src/commands/discovery.rs b/ushadow/launcher/src-tauri/src/commands/discovery.rs index 79752500..d6ecb96f 100644 --- a/ushadow/launcher/src-tauri/src/commands/discovery.rs +++ b/ushadow/launcher/src-tauri/src/commands/discovery.rs @@ -1,4 +1,6 @@ use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use crate::models::{DiscoveryResult, EnvironmentStatus, InfraService, UshadowEnvironment, WorktreeInfo}; use super::prerequisites::{check_docker, check_tailscale}; use super::utils::silent_command; @@ -19,6 +21,9 @@ struct EnvContainerInfo { has_running: bool, } +// Cache tailscale status for 10 seconds to avoid slow repeated checks +static TAILSCALE_CACHE: Mutex> = Mutex::new(None); + /// Discover Ushadow environments and infrastructure (running and stopped) #[tauri::command] pub async fn discover_environments() -> Result { @@ -33,10 +38,30 @@ pub async fn discover_environments_with_config( ) -> Result { // Check prerequisites let (docker_installed, docker_running, _) = check_docker(); - let (tailscale_installed, tailscale_connected, _) = check_tailscale(); + + // Cache tailscale checks - they're slow and rarely change + let tailscale_ok = { + let mut cache = TAILSCALE_CACHE.lock().unwrap(); + let now = Instant::now(); + + if let Some((cached_ok, cached_time)) = *cache { + if now.duration_since(cached_time) < Duration::from_secs(10) { + cached_ok + } else { + let (installed, connected, _) = check_tailscale(); + let ok = installed && connected; + *cache = Some((ok, now)); + ok + } + } else { + let (installed, connected, _) = check_tailscale(); + let ok = installed && connected; + *cache = Some((ok, now)); + ok + } + }; let docker_ok = docker_installed && docker_running; - let tailscale_ok = tailscale_installed && tailscale_connected; // Default paths let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); diff --git a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs index 9ca8163e..99fcae64 100644 --- a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs +++ b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs @@ -310,6 +310,56 @@ pub fn check_homebrew() -> (bool, Option) { (false, None) // Homebrew not applicable on non-macOS } +/// Check if workmux is installed +pub fn check_workmux() -> (bool, Option) { + // Mock mode for testing + if is_mock_mode() { + let installed = env::var("MOCK_WORKMUX_INSTALLED").unwrap_or_default() == "true"; + let version = if installed { + Some("workmux 0.1.1 (MOCKED)".to_string()) + } else { + None + }; + return (installed, version); + } + + let version_output = shell_command("workmux --version") + .output(); + + match version_output { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (true, Some(version)) + } + _ => (false, None), + } +} + +/// Check if tmux is installed +pub fn check_tmux() -> (bool, Option) { + // Mock mode for testing + if is_mock_mode() { + let installed = env::var("MOCK_TMUX_INSTALLED").unwrap_or_default() == "true"; + let version = if installed { + Some("tmux 3.3a (MOCKED)".to_string()) + } else { + None + }; + return (installed, version); + } + + let version_output = shell_command("tmux -V") + .output(); + + match version_output { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (true, Some(version)) + } + _ => (false, None), + } +} + /// Get full prerequisite status #[tauri::command] pub fn check_prerequisites() -> Result { @@ -318,6 +368,8 @@ pub fn check_prerequisites() -> Result { let (tailscale_installed, tailscale_connected, tailscale_version) = check_tailscale(); let (git_installed, git_version) = check_git(); let (python_installed, python_version) = check_python(); + let (workmux_installed, workmux_version) = check_workmux(); + let (tmux_installed, tmux_version) = check_tmux(); Ok(PrerequisiteStatus { homebrew_installed, @@ -327,11 +379,15 @@ pub fn check_prerequisites() -> Result { tailscale_connected, git_installed, python_installed, + workmux_installed, + tmux_installed, homebrew_version, docker_version, tailscale_version, git_version, python_version, + workmux_version, + tmux_version, }) } diff --git a/ushadow/launcher/src-tauri/src/commands/utils.rs b/ushadow/launcher/src-tauri/src/commands/utils.rs index 45530a6d..81508af0 100644 --- a/ushadow/launcher/src-tauri/src/commands/utils.rs +++ b/ushadow/launcher/src-tauri/src/commands/utils.rs @@ -1,5 +1,4 @@ use std::process::Command; -use std::path::{Path, PathBuf}; /// Create a new Command that won't open a console window on Windows. /// This is essential for background polling commands that shouldn't flash windows. diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index b2707160..29e3e6e4 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -1,7 +1,8 @@ -use crate::models::WorktreeInfo; +use crate::models::{WorktreeInfo, TmuxSessionInfo, TmuxWindowInfo}; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; +use super::utils::shell_command; /// Get color name for an environment name /// Returns the color name that the frontend will use to look up hex codes @@ -25,6 +26,79 @@ pub fn get_colors_for_name(name: &str) -> (String, String) { (name.to_string(), name.to_string()) } +/// Check if a worktree exists for a given branch +#[tauri::command] +pub async fn check_worktree_exists(main_repo: String, branch: String) -> Result, String> { + let branch = branch.to_lowercase(); + + let output = Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to list worktrees: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Git command failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut current: HashMap = HashMap::new(); + + for line in stdout.lines() { + if line.is_empty() { + if let Some(path) = current.get("worktree") { + let current_branch = current.get("branch") + .map(|b| b.replace("refs/heads/", "").to_lowercase()) + .unwrap_or_default(); + + // Check if this worktree has the branch we're looking for + if current_branch == branch && !current.contains_key("bare") { + let path_buf = PathBuf::from(path); + let name = path_buf.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + return Ok(Some(WorktreeInfo { + path: path.clone(), + branch: current_branch, + name, + })); + } + } + current.clear(); + } else if line.starts_with("worktree ") { + current.insert("worktree".to_string(), line[9..].to_string()); + } else if line.starts_with("branch ") { + current.insert("branch".to_string(), line[7..].to_string()); + } else if line.starts_with("bare") { + current.insert("bare".to_string(), "true".to_string()); + } + } + + // Process last entry if exists + if let Some(path) = current.get("worktree") { + let current_branch = current.get("branch") + .map(|b| b.replace("refs/heads/", "").to_lowercase()) + .unwrap_or_default(); + + if current_branch == branch && !current.contains_key("bare") { + let path_buf = PathBuf::from(path); + let name = path_buf.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + return Ok(Some(WorktreeInfo { + path: path.clone(), + branch: current_branch, + name, + })); + } + } + + Ok(None) +} + /// List all git worktrees in a repository #[tauri::command] pub async fn list_worktrees(main_repo: String) -> Result, String> { @@ -105,6 +179,9 @@ pub async fn create_worktree( name: String, base_branch: Option, ) -> Result { + // Force lowercase to avoid Docker Compose naming issues + let name = name.to_lowercase(); + // Extract project name from main_repo path (last directory component) let project_name = PathBuf::from(&main_repo) .file_name() @@ -123,8 +200,8 @@ pub async fn create_worktree( eprintln!("[create_worktree] Created project worktrees directory: {}", project_worktrees_dir.display()); } - // Determine the desired branch name - let desired_branch = base_branch.clone().unwrap_or_else(|| name.clone()); + // Determine the desired branch name (also lowercase) + let desired_branch = base_branch.map(|b| b.to_lowercase()).unwrap_or_else(|| name.clone()); // Check if git has this worktree registered or if the branch is in use let list_output = Command::new("git") @@ -156,7 +233,7 @@ pub async fn create_worktree( } // Check the next lines for branch info - let mut current_worktree_path = path.to_string(); + let current_worktree_path = path.to_string(); let mut current_branch: Option = None; for next_line in lines.by_ref() { @@ -271,10 +348,20 @@ pub async fn create_worktree( /// Open a path in VS Code with environment-specific colors #[tauri::command] pub async fn open_in_vscode(path: String, env_name: Option) -> Result<(), String> { + open_in_vscode_impl(path, env_name, false).await +} + +/// Open a path in VS Code and attach to tmux in integrated terminal +#[tauri::command] +pub async fn open_in_vscode_with_tmux(path: String, env_name: String) -> Result<(), String> { + open_in_vscode_impl(path, Some(env_name), true).await +} + +async fn open_in_vscode_impl(path: String, env_name: Option, with_tmux: bool) -> Result<(), String> { use super::utils::shell_command; // If env_name is provided, set up VSCode colors using the Python utility - if let Some(name) = env_name { + if let Some(name) = &env_name { eprintln!("[open_in_vscode] Setting up VSCode colors for environment: {}", name); // Run Python script to set up colors in the environment directory @@ -296,15 +383,204 @@ pub async fn open_in_vscode(path: String, env_name: Option) -> Result<() } } - // Open VS Code - let output = Command::new("code") + // Open VS Code (don't wait for it to finish) + Command::new("code") .arg(&path) - .output() + .spawn() .map_err(|e| format!("Failed to open VS Code: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("VS Code command failed: {}", stderr)); + // If with_tmux is true, create a shell script that VS Code can run + if with_tmux && env_name.is_some() { + let env_name_lower = env_name.unwrap().to_lowercase(); + let window_name = format!("ushadow-{}", env_name_lower); + + eprintln!("[open_in_vscode] Creating tmux attach script for VS Code terminal"); + + // Ensure tmux is running and window exists + eprintln!("[open_in_vscode] Ensuring tmux is running..."); + match ensure_tmux_running().await { + Ok(msg) => eprintln!("[open_in_vscode] {}", msg), + Err(e) => { + eprintln!("[open_in_vscode] ERROR: Failed to start tmux: {}", e); + return Err(format!("Failed to start tmux: {}", e)); + } + } + + // Check if window exists, create if not + eprintln!("[open_in_vscode] Checking for tmux window '{}'...", window_name); + let check_window = shell_command(&format!("tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", window_name)) + .output(); + + let window_exists = matches!(check_window, Ok(ref output) if output.status.success()); + + // Create .tmux.conf BEFORE creating the window (so we can source it) + let tmux_conf_path = format!("{}/.tmux.conf", path); + let tmux_conf_content = "# User-friendly tmux configuration for Ushadow environments\n\ +\n\ +# Enable mouse support (scroll, select, resize panes)\n\ +set -g mouse on\n\ +\n\ +# Increase scrollback buffer\n\ +set -g history-limit 50000\n\ +\n\ +# Don't rename windows automatically\n\ +set -g allow-rename off\n\ +\n\ +# Start window numbering at 1\n\ +set -g base-index 1\n\ +\n\ +# Enable 256 colors\n\ +set -g default-terminal \"screen-256color\"\n\ +\n\ +# Faster command sequences\n\ +set -s escape-time 0\n\ +\n\ +# Status bar styling\n\ +set -g status-style bg=default,fg=white\n\ +set -g status-left-length 40\n\ +set -g status-right \"#[fg=yellow]#S #[fg=white]%H:%M\"\n\ +\n\ +# Pane border colors\n\ +set -g pane-border-style fg=colour238\n\ +set -g pane-active-border-style fg=colour39\n\ +\n\ +# Fix mouse scrolling in terminal applications\n\ +set -g terminal-overrides 'xterm*:smcup@:rmcup@'\n\ +"; + + std::fs::write(&tmux_conf_path, tmux_conf_content) + .map_err(|e| format!("Failed to write .tmux.conf: {}", e))?; + + eprintln!("[open_in_vscode] Created user-friendly .tmux.conf"); + + // Reload tmux config if session is already running + let reload_config = shell_command(&format!( + "tmux source-file '{}'", + tmux_conf_path + )) + .output(); + + if let Ok(output) = reload_config { + if output.status.success() { + eprintln!("[open_in_vscode] Reloaded tmux config for existing session"); + } + } + + if !window_exists { + eprintln!("[open_in_vscode] Creating tmux window '{}'...", window_name); + // Create the window + let create_window = shell_command(&format!( + "cd '{}' && tmux -f .tmux.conf new-window -t workmux -n {} -c '{}'", + path, window_name, path + )) + .output() + .map_err(|e| format!("Failed to create tmux window: {}", e))?; + + if !create_window.status.success() { + let stderr = String::from_utf8_lossy(&create_window.stderr); + eprintln!("[open_in_vscode] ERROR: Failed to create tmux window: {}", stderr); + return Err(format!("Failed to create tmux window: {}", stderr)); + } else { + eprintln!("[open_in_vscode] ✓ Created tmux window '{}'", window_name); + } + } else { + eprintln!("[open_in_vscode] ✓ Tmux window '{}' already exists", window_name); + } + + // Create .vscode directory if it doesn't exist + let vscode_dir = format!("{}/.vscode", path); + std::fs::create_dir_all(&vscode_dir) + .map_err(|e| format!("Failed to create .vscode directory: {}", e))?; + + // Create settings.json with tmux terminal profile + let settings_path = format!("{}/settings.json", vscode_dir); + + // Read existing settings if any + let mut settings: serde_json::Value = if let Ok(existing) = std::fs::read_to_string(&settings_path) { + serde_json::from_str(&existing).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + // Add/update terminal profiles + #[cfg(target_os = "macos")] + { + // Get user's shell from environment or default to zsh + let user_shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + + // Command that creates a NEW pane each time a new terminal is opened + let tmux_command = format!( + "tmux -f .tmux.conf has-session -t workmux:{} 2>/dev/null && \ + tmux -f .tmux.conf split-window -t workmux:{} || \ + tmux -f .tmux.conf attach-session -t workmux:{} || \ + exec $SHELL -l", + window_name, window_name, window_name + ); + + settings["terminal.integrated.profiles.osx"] = serde_json::json!({ + "tmux": { + "path": user_shell, + "args": ["-l", "-c", tmux_command], + "icon": "terminal" + } + }); + settings["terminal.integrated.defaultProfile.osx"] = serde_json::json!("tmux"); + } + + #[cfg(target_os = "linux")] + { + let tmux_command = format!( + "tmux -f .tmux.conf has-session -t workmux:{} 2>/dev/null && \ + tmux -f .tmux.conf split-window -t workmux:{} || \ + tmux -f .tmux.conf attach-session -t workmux:{} || \ + bash", + window_name, window_name, window_name + ); + + settings["terminal.integrated.profiles.linux"] = serde_json::json!({ + "tmux": { + "path": "/bin/bash", + "args": ["-c", tmux_command], + "icon": "terminal" + } + }); + settings["terminal.integrated.defaultProfile.linux"] = serde_json::json!("tmux"); + } + + #[cfg(target_os = "windows")] + { + let tmux_command = format!( + "tmux -f .tmux.conf has-session -t workmux:{} 2>/dev/null && \ + tmux -f .tmux.conf split-window -t workmux:{} || \ + tmux -f .tmux.conf attach-session -t workmux:{} || \ + bash", + window_name, window_name, window_name + ); + + settings["terminal.integrated.profiles.windows"] = serde_json::json!({ + "tmux": { + "path": "bash.exe", + "args": ["-c", tmux_command], + "icon": "terminal" + } + }); + settings["terminal.integrated.defaultProfile.windows"] = serde_json::json!("tmux"); + } + + // Write back settings + let settings_content = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + std::fs::write(&settings_path, settings_content) + .map_err(|e| format!("Failed to write settings.json: {}", e))?; + + eprintln!("[open_in_vscode] Configured VS Code to use tmux terminal by default"); + eprintln!("[open_in_vscode] Open a terminal in VS Code with Cmd+Shift+` to connect to tmux"); + + // Note: We don't auto-open the terminal with AppleScript because it can + // cause focus issues when multiple VS Code windows are open. + // VS Code is already configured to use tmux, so users can manually open + // the terminal with Cmd+Shift+` (or Ctrl+Shift+` on Linux/Windows) } Ok(()) @@ -333,3 +609,566 @@ pub async fn remove_worktree(main_repo: String, name: String) -> Result<(), Stri Ok(()) } + +/// Delete an environment completely - stop containers, remove worktree, close tmux +#[tauri::command] +pub async fn delete_environment(main_repo: String, env_name: String) -> Result { + let env_name = env_name.to_lowercase(); + eprintln!("[delete_environment] Deleting environment '{}'", env_name); + + let mut messages = Vec::new(); + + // Step 1: Stop containers (best effort - don't fail if they're already stopped) + eprintln!("[delete_environment] Stopping containers for '{}'...", env_name); + let stop_result = shell_command(&format!("docker compose -p ushadow-{} down", env_name)) + .output(); + + match stop_result { + Ok(output) if output.status.success() => { + messages.push(format!("✓ Stopped containers for '{}'", env_name)); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("No such file") && !stderr.to_lowercase().contains("not found") { + eprintln!("[delete_environment] Warning: Failed to stop containers: {}", stderr); + messages.push(format!("⚠ Could not stop containers (may already be stopped)")); + } + } + Err(e) => { + eprintln!("[delete_environment] Warning: Failed to run docker compose down: {}", e); + messages.push(format!("⚠ Could not stop containers (may already be stopped)")); + } + } + + // Step 2: Close tmux window if it exists + let window_name = format!("ushadow-{}", env_name); + eprintln!("[delete_environment] Closing tmux window '{}'...", window_name); + let close_result = shell_command(&format!("tmux kill-window -t {}", window_name)) + .output(); + + match close_result { + Ok(output) if output.status.success() => { + messages.push(format!("✓ Closed tmux window '{}'", window_name)); + } + Ok(_) | Err(_) => { + // Tmux window might not exist, that's fine + eprintln!("[delete_environment] No tmux window found for '{}'", window_name); + } + } + + // Step 3: Remove the worktree + eprintln!("[delete_environment] Removing worktree '{}'...", env_name); + match remove_worktree(main_repo, env_name.clone()).await { + Ok(_) => { + messages.push(format!("✓ Removed worktree '{}'", env_name)); + } + Err(e) => { + return Err(format!("Failed to remove worktree: {}", e)); + } + } + + Ok(messages.join("\n")) +} + +/// Create a worktree using workmux (includes tmux integration) +/// Falls back to regular git worktree if tmux is not available +#[tauri::command] +pub async fn create_worktree_with_workmux( + main_repo: String, + name: String, + base_branch: Option, + background: Option, +) -> Result { + // Force lowercase to avoid Docker Compose naming issues + let name = name.to_lowercase(); + let base_branch = base_branch.map(|b| b.to_lowercase()); + + eprintln!("[create_worktree_with_workmux] Creating worktree '{}' from branch '{:?}'", name, base_branch); + + // Check if tmux is running - if not, auto-start it + let tmux_check = shell_command("tmux list-sessions") + .output(); + + let tmux_available = matches!(tmux_check, Ok(output) if output.status.success()); + + if !tmux_available { + eprintln!("[create_worktree_with_workmux] tmux not running, attempting to start a new session"); + + // Try to start a new detached tmux session + let start_tmux = shell_command("tmux new-session -d -s workmux") + .output(); + + match start_tmux { + Ok(output) if output.status.success() => { + eprintln!("[create_worktree_with_workmux] Successfully started tmux session 'workmux'"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + // Session might already exist, which is fine + if !stderr.contains("duplicate session") { + eprintln!("[create_worktree_with_workmux] Failed to start tmux: {}", stderr); + eprintln!("[create_worktree_with_workmux] Falling back to regular git worktree"); + let main_repo_path = PathBuf::from(&main_repo); + let worktrees_dir = main_repo_path.parent() + .ok_or("Could not determine worktrees directory")? + .to_string_lossy() + .to_string(); + + return create_worktree(main_repo, worktrees_dir, name, base_branch).await; + } + } + Err(e) => { + eprintln!("[create_worktree_with_workmux] Failed to start tmux: {}", e); + eprintln!("[create_worktree_with_workmux] Falling back to regular git worktree"); + let main_repo_path = PathBuf::from(&main_repo); + let worktrees_dir = main_repo_path.parent() + .ok_or("Could not determine worktrees directory")? + .to_string_lossy() + .to_string(); + + return create_worktree(main_repo, worktrees_dir, name, base_branch).await; + } + } + } + + // Build workmux add command + let mut cmd_parts = vec!["workmux", "add"]; + + if background.unwrap_or(false) { + cmd_parts.push("--background"); + } + + // Skip hooks and pane commands for faster creation (we don't need agent prompts) + cmd_parts.extend(&["--no-hooks", "--no-pane-cmds"]); + + if let Some(ref branch) = base_branch { + cmd_parts.push("--base"); + cmd_parts.push(branch); + } + + cmd_parts.push(&name); + + let workmux_cmd = cmd_parts.join(" "); + eprintln!("[create_worktree_with_workmux] Running: {}", workmux_cmd); + + // Execute workmux add + let output = shell_command(&workmux_cmd) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to run workmux: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // If workmux fails because tmux isn't running, fall back + if stderr.contains("tmux is not running") || stderr.contains("tmux") { + eprintln!("[create_worktree_with_workmux] Workmux failed (tmux issue), falling back to regular git worktree"); + let main_repo_path = PathBuf::from(&main_repo); + let worktrees_dir = main_repo_path.parent() + .ok_or("Could not determine worktrees directory")? + .to_string_lossy() + .to_string(); + + return create_worktree(main_repo, worktrees_dir, name, base_branch).await; + } + + return Err(format!("Workmux command failed:\nstdout: {}\nstderr: {}", stdout, stderr)); + } + + eprintln!("[create_worktree_with_workmux] Workmux add completed successfully"); + + // Get worktree info from git + let worktrees = list_worktrees(main_repo).await?; + worktrees.iter() + .find(|wt| wt.name == name) + .cloned() + .ok_or_else(|| format!("Worktree '{}' was created but not found in list", name)) +} + +/// Merge a worktree branch and clean up using workmux +#[tauri::command] +pub async fn merge_worktree_with_rebase( + main_repo: String, + name: String, + use_rebase: bool, + keep_worktree: bool, +) -> Result { + // Force lowercase to match worktree naming + let name = name.to_lowercase(); + + eprintln!("[merge_worktree_with_rebase] Merging worktree '{}' (rebase: {}, keep: {})", name, use_rebase, keep_worktree); + + // Build workmux merge command + let mut cmd_parts = vec!["workmux", "merge"]; + + if use_rebase { + cmd_parts.push("--rebase"); + } + + if keep_worktree { + cmd_parts.push("--keep"); + } + + cmd_parts.push(&name); + + let workmux_cmd = cmd_parts.join(" "); + eprintln!("[merge_worktree_with_rebase] Running: {}", workmux_cmd); + + // Execute workmux merge + let output = shell_command(&workmux_cmd) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to run workmux merge: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!("Workmux merge failed:\nstdout: {}\nstderr: {}", stdout, stderr)); + } + + Ok(format!("Merged and cleaned up worktree '{}'\n{}", name, stdout)) +} + +/// List active tmux sessions to monitor agent status +#[tauri::command] +pub async fn list_tmux_sessions() -> Result, String> { + let output = shell_command("tmux list-sessions -F '#{session_name}'") + .output() + .map_err(|e| format!("Failed to list tmux sessions: {}", e))?; + + if !output.status.success() { + // tmux returns error if no sessions exist - this is ok + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let sessions: Vec = stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + Ok(sessions) +} + +/// Get tmux window status for a specific worktree +#[tauri::command] +pub async fn get_tmux_window_status(window_name: String) -> Result, String> { + // Check if window exists and get its status + let output = shell_command(&format!("tmux list-windows -a -F '#{{window_name}} #{{pane_current_command}}' | grep '^{}'", window_name)) + .output() + .map_err(|e| format!("Failed to check tmux window: {}", e))?; + + if !output.status.success() { + return Ok(None); // Window doesn't exist + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let status = stdout.trim().to_string(); + + Ok(Some(status)) +} + +/// Ensure tmux server is running and workmux session exists +#[tauri::command] +pub async fn ensure_tmux_running() -> Result { + // Check if tmux is running + let tmux_check = shell_command("tmux list-sessions") + .output(); + + if matches!(tmux_check, Ok(ref output) if output.status.success()) { + return Ok("Tmux server already running".to_string()); + } + + eprintln!("[ensure_tmux_running] Starting tmux server with workmux session"); + + // Start a new detached tmux session + let start_tmux = shell_command("tmux new-session -d -s workmux") + .output() + .map_err(|e| format!("Failed to start tmux: {}", e))?; + + if !start_tmux.status.success() { + let stderr = String::from_utf8_lossy(&start_tmux.stderr); + return Err(format!("Failed to start tmux: {}", stderr)); + } + + Ok("Tmux server started with session 'workmux'".to_string()) +} + +/// Get all tmux sessions and windows info (legacy string format) +#[tauri::command] +pub async fn get_tmux_info() -> Result { + // Check if tmux is running + let sessions_output = shell_command("tmux list-sessions") + .output(); + + if !matches!(sessions_output, Ok(ref output) if output.status.success()) { + return Ok("No tmux server running\n\nClick 'Start Tmux Server' to create a tmux session.".to_string()); + } + + let mut info = String::new(); + + // Get sessions + info.push_str("=== Tmux Sessions ===\n"); + if let Ok(output) = sessions_output { + let stdout = String::from_utf8_lossy(&output.stdout); + info.push_str(&stdout); + } + + info.push_str("\n=== Tmux Windows ===\n"); + + // Get windows with details + let windows_output = shell_command("tmux list-windows -a -F '#{session_name}:#{window_index}:#{window_name} - #{pane_current_command}'") + .output() + .map_err(|e| format!("Failed to list tmux windows: {}", e))?; + + if windows_output.status.success() { + let stdout = String::from_utf8_lossy(&windows_output.stdout); + info.push_str(&stdout); + } else { + info.push_str("No windows found\n"); + } + + Ok(info) +} + +/// Attach or create a tmux window for an existing worktree +#[tauri::command] +pub async fn attach_tmux_to_worktree(worktree_path: String, env_name: String) -> Result { + let env_name = env_name.to_lowercase(); + let window_name = format!("ushadow-{}", env_name); + + // Ensure tmux is running + ensure_tmux_running().await?; + + // Check if window already exists + let check_window = shell_command(&format!("tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", window_name)) + .output(); + + let window_existed = matches!(check_window, Ok(ref output) if output.status.success()); + + // Create window if it doesn't exist + if !window_existed { + let create_window = shell_command(&format!( + "tmux new-window -t workmux -n {} -c '{}'", + window_name, worktree_path + )) + .output() + .map_err(|e| format!("Failed to create tmux window: {}", e))?; + + if !create_window.status.success() { + let stderr = String::from_utf8_lossy(&create_window.stderr); + return Err(format!("Failed to create tmux window: {}", stderr)); + } + } + + // Open Terminal.app and attach to the tmux window + #[cfg(target_os = "macos")] + { + let script = format!( + "tell application \"Terminal\" to do script \"tmux attach-session -t workmux:{} && exit\"", + window_name + ); + + let open_terminal = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| format!("Failed to open Terminal: {}", e))?; + + if !open_terminal.status.success() { + let stderr = String::from_utf8_lossy(&open_terminal.stderr); + eprintln!("[attach_tmux_to_worktree] Warning: Failed to open Terminal.app: {}", stderr); + // Don't fail the whole operation if Terminal opening fails + } + } + + #[cfg(not(target_os = "macos"))] + { + // For Linux/Windows, try using default terminal + let _open_terminal = Command::new("x-terminal-emulator") + .arg("-e") + .arg(format!("tmux attach-session -t workmux:{}", window_name)) + .spawn(); + // Don't fail if this doesn't work + } + + let message = if window_existed { + format!("Opened tmux window '{}' (already existed)", window_name) + } else { + format!("Created and opened tmux window '{}' in {}", window_name, worktree_path) + }; + + Ok(message) +} + +/// Get comprehensive tmux status for an environment +#[tauri::command] +pub async fn get_environment_tmux_status(env_name: String) -> Result { + use crate::models::{TmuxStatus, TmuxActivityStatus}; + + // Check if tmux is running + let tmux_check = shell_command("tmux list-sessions") + .output(); + + if !matches!(tmux_check, Ok(output) if output.status.success()) { + // tmux not running + return Ok(TmuxStatus { + exists: false, + window_name: None, + current_command: None, + activity_status: TmuxActivityStatus::Unknown, + }); + } + + // Workmux prefixes windows with "ushadow-" + let window_name = format!("ushadow-{}", env_name); + + // Get window info: name, current command, and pane tty + let output = shell_command(&format!( + "tmux list-panes -a -F '#{{window_name}} #{{pane_current_command}} #{{pane_tty}}' | grep '^{}'", + window_name + )) + .output() + .map_err(|e| format!("Failed to check tmux window: {}", e))?; + + if !output.status.success() { + // Window doesn't exist + return Ok(TmuxStatus { + exists: false, + window_name: None, + current_command: None, + activity_status: TmuxActivityStatus::Unknown, + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = stdout.trim().split_whitespace().collect(); + + if parts.len() < 2 { + return Ok(TmuxStatus { + exists: false, + window_name: None, + current_command: None, + activity_status: TmuxActivityStatus::Unknown, + }); + } + + let current_command = parts[1].to_string(); + + // Determine activity status based on current command + let activity_status = match current_command.as_str() { + "bash" | "zsh" | "sh" | "fish" => TmuxActivityStatus::Waiting, + "claude" | "vim" | "nvim" | "emacs" | "nano" => TmuxActivityStatus::Working, + "python" | "node" | "npm" | "cargo" | "make" => TmuxActivityStatus::Working, + _ => { + // For other commands, check if they're long-running processes + if current_command.starts_with("python") || current_command.starts_with("node") { + TmuxActivityStatus::Working + } else { + TmuxActivityStatus::Unknown + } + } + }; + + Ok(TmuxStatus { + exists: true, + window_name: Some(window_name), + current_command: Some(current_command), + activity_status, + }) +} + +/// Get all tmux sessions with their windows +#[tauri::command] +pub async fn get_tmux_sessions() -> Result, String> { + // Check if tmux is running + let check = shell_command("tmux list-sessions") + .output() + .map_err(|e| format!("Failed to check tmux: {}", e))?; + + if !check.status.success() { + // No tmux server running + return Ok(vec![]); + } + + // Get session list + let sessions_output = shell_command("tmux list-sessions -F '#{session_name}'") + .output() + .map_err(|e| format!("Failed to list sessions: {}", e))?; + + let session_names: Vec = String::from_utf8_lossy(&sessions_output.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let mut sessions = Vec::new(); + + for session_name in session_names { + // Get windows for this session + let windows_output = shell_command(&format!( + "tmux list-windows -t {} -F '#{{window_index}}|#{{window_name}}|#{{window_active}}|#{{window_panes}}'", + session_name + )) + .output() + .map_err(|e| format!("Failed to list windows: {}", e))?; + + let windows: Vec = String::from_utf8_lossy(&windows_output.stdout) + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 4 { + Some(TmuxWindowInfo { + index: parts[0].to_string(), + name: parts[1].to_string(), + active: parts[2] == "1", + panes: parts[3].parse().unwrap_or(1), + }) + } else { + None + } + }) + .collect(); + + sessions.push(TmuxSessionInfo { + name: session_name, + window_count: windows.len(), + windows, + }); + } + + Ok(sessions) +} + +/// Kill a specific tmux window +#[tauri::command] +pub async fn kill_tmux_window(window_name: String) -> Result { + let output = shell_command(&format!("tmux kill-window -t {}", window_name)) + .output() + .map_err(|e| format!("Failed to kill window: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to kill window '{}': {}", window_name, stderr)); + } + + Ok(format!("Killed tmux window '{}'", window_name)) +} + +/// Kill the entire tmux server (all sessions and windows) +#[tauri::command] +pub async fn kill_tmux_server() -> Result { + let output = shell_command("tmux kill-server") + .output() + .map_err(|e| format!("Failed to kill tmux server: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to kill tmux server: {}", stderr)); + } + + Ok("Killed tmux server".to_string()) +} diff --git a/ushadow/launcher/src-tauri/src/main.rs b/ushadow/launcher/src-tauri/src/main.rs index 9b0df8dd..60dba83c 100644 --- a/ushadow/launcher/src-tauri/src/main.rs +++ b/ushadow/launcher/src-tauri/src/main.rs @@ -21,7 +21,11 @@ use commands::{AppState, check_prerequisites, discover_environments, get_os_type get_default_project_dir, check_project_dir, clone_ushadow_repo, update_ushadow_repo, install_git_windows, install_git_macos, // Worktree commands - list_worktrees, create_worktree, open_in_vscode, remove_worktree, + list_worktrees, check_worktree_exists, create_worktree, create_worktree_with_workmux, + merge_worktree_with_rebase, list_tmux_sessions, get_tmux_window_status, + get_environment_tmux_status, get_tmux_info, ensure_tmux_running, attach_tmux_to_worktree, + open_in_vscode, open_in_vscode_with_tmux, remove_worktree, delete_environment, + get_tmux_sessions, kill_tmux_window, kill_tmux_server, // Permissions check_install_path}; use tauri::{ @@ -142,9 +146,23 @@ fn main() { // Worktree management discover_environments_with_config, list_worktrees, + check_worktree_exists, create_worktree, + create_worktree_with_workmux, + merge_worktree_with_rebase, + list_tmux_sessions, + get_tmux_window_status, + get_environment_tmux_status, + get_tmux_info, + ensure_tmux_running, + attach_tmux_to_worktree, open_in_vscode, + open_in_vscode_with_tmux, remove_worktree, + delete_environment, + get_tmux_sessions, + kill_tmux_window, + kill_tmux_server, ]) .setup(|app| { let window = app.get_window("main").unwrap(); diff --git a/ushadow/launcher/src-tauri/src/models.rs b/ushadow/launcher/src-tauri/src/models.rs index 0f795ef7..cfea78b9 100644 --- a/ushadow/launcher/src-tauri/src/models.rs +++ b/ushadow/launcher/src-tauri/src/models.rs @@ -10,11 +10,15 @@ pub struct PrerequisiteStatus { pub tailscale_connected: bool, pub git_installed: bool, pub python_installed: bool, + pub workmux_installed: bool, + pub tmux_installed: bool, pub homebrew_version: Option, pub docker_version: Option, pub tailscale_version: Option, pub git_version: Option, pub python_version: Option, + pub workmux_version: Option, + pub tmux_version: Option, } /// Project location status @@ -93,3 +97,37 @@ pub struct DiscoveryResult { pub docker_ok: bool, pub tailscale_ok: bool, } + +/// Tmux session status for an environment +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TmuxStatus { + pub exists: bool, + pub window_name: Option, + pub current_command: Option, + pub activity_status: TmuxActivityStatus, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum TmuxActivityStatus { + Working, // 🤖 - actively running commands + Waiting, // 💬 - shell prompt, waiting for input + Done, // ✅ - command completed successfully + Error, // ❌ - command failed + Unknown, // No status available +} + +/// Tmux session information for management UI +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TmuxSessionInfo { + pub name: String, + pub window_count: usize, + pub windows: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TmuxWindowInfo { + pub name: String, + pub index: String, + pub active: bool, + pub panes: usize, +} diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index 31f62205..35cfa751 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { tauri, type Prerequisites, type Discovery } from './hooks/useTauri' +import { tauri, type Prerequisites, type Discovery, type UshadowEnvironment } from './hooks/useTauri' import { useAppStore } from './store/appStore' import { useWindowFocus } from './hooks/useWindowFocus' +import { useTmuxMonitoring } from './hooks/useTmuxMonitoring' import { DevToolsPanel } from './components/DevToolsPanel' import { PrerequisitesPanel } from './components/PrerequisitesPanel' import { InfrastructurePanel } from './components/InfrastructurePanel' @@ -10,8 +11,9 @@ import { FoldersPanel } from './components/FoldersPanel' import { LogPanel, type LogEntry, type LogLevel } from './components/LogPanel' import { ProjectSetupDialog } from './components/ProjectSetupDialog' import { NewEnvironmentDialog } from './components/NewEnvironmentDialog' +import { TmuxManagerDialog } from './components/TmuxManagerDialog' import { EmbeddedView } from './components/EmbeddedView' -import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil } from 'lucide-react' +import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal } from 'lucide-react' import { getColors } from './utils/colors' function App() { @@ -42,6 +44,7 @@ function App() { const [loadingEnv, setLoadingEnv] = useState(null) const [showProjectDialog, setShowProjectDialog] = useState(false) const [showNewEnvDialog, setShowNewEnvDialog] = useState(false) + const [showTmuxManager, setShowTmuxManager] = useState(false) const [logExpanded, setLogExpanded] = useState(true) const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string } | null>(null) const [creatingEnvs, setCreatingEnvs] = useState<{ name: string; status: 'cloning' | 'starting' | 'error'; path?: string; error?: string }[]>([]) @@ -52,6 +55,10 @@ function App() { // Window focus detection for smart polling const isWindowFocused = useWindowFocus() + // Tmux monitoring for agent status (only when window is focused and worktrees exist) + const environmentNames = discovery?.environments.map(e => e.name) ?? [] + const tmuxStatuses = useTmuxMonitoring(environmentNames, isWindowFocused && environmentNames.length > 0) + const logIdRef = useRef(0) const lastStateRef = useRef('') @@ -118,11 +125,15 @@ function App() { tailscale_installed: spoofedPrereqs.tailscale_installed ?? real.tailscale_installed, tailscale_connected: real.tailscale_connected, python_installed: spoofedPrereqs.python_installed ?? real.python_installed, + workmux_installed: real.workmux_installed, + tmux_installed: real.tmux_installed, homebrew_version: real.homebrew_version, docker_version: real.docker_version, tailscale_version: real.tailscale_version, git_version: real.git_version, python_version: real.python_version, + workmux_version: real.workmux_version, + tmux_version: real.tmux_version, } }, [spoofedPrereqs]) @@ -154,6 +165,19 @@ function App() { const disc = await tauri.discoverEnvironments() setDiscovery(disc) + // Auto-start tmux if worktrees exist but tmux isn't running + const worktrees = disc.environments.filter(e => e.is_worktree) + if (worktrees.length > 0) { + try { + await tauri.ensureTmuxRunning() + } catch (err) { + // Non-critical, just log it + if (!silent) { + console.log('Could not ensure tmux is running:', err) + } + } + } + if (!silent) { const runningCount = disc.infrastructure.filter(s => s.running).length const envCount = disc.environments.length @@ -508,7 +532,7 @@ function App() { } } else { // Create a mock environment - const mockEnv = { + const mockEnv: UshadowEnvironment = { name: envName, color: envName, localhost_url: `http://localhost:8000`, @@ -516,9 +540,12 @@ function App() { backend_port: 8000, webui_port: 3000, running: true, + status: 'Running' as const, tailscale_active: false, containers: ['backend', 'webui', 'postgres', 'redis'], path: projectRoot, + branch: null, + is_worktree: false, } return { ...prev, @@ -600,9 +627,107 @@ function App() { setEmbeddedView({ url, envName: env.name, envColor: colors.primary }) } + const handleMerge = async (envName: string) => { + // Confirm with user before merging + const confirmed = window.confirm( + `Merge worktree "${envName}" to main?\n\n` + + `This will:\n` + + `• Rebase your branch onto main\n` + + `• Merge to main\n` + + `• Stop and remove the environment\n` + + `• Delete the worktree and close the tmux window\n\n` + + `Make sure all your changes are committed!` + ) + + if (!confirmed) return + + setLoadingEnv(envName) + log(`Merging worktree "${envName}" to main...`, 'step') + + try { + // First stop the environment if running + const env = discovery?.environments.find(e => e.name === envName) + if (env?.running) { + log(`Stopping environment before merge...`, 'info') + await tauri.stopEnvironment(envName) + } + + // Merge with workmux + const result = await tauri.mergeWorktreeWithRebase( + projectRoot, + envName, + true, // use rebase + false // don't keep worktree + ) + + log(result, 'success') + log(`✓ Worktree "${envName}" merged and cleaned up`, 'success') + + // Refresh discovery to update environment list + await refreshDiscovery() + } catch (err) { + log(`Failed to merge worktree: ${err}`, 'error') + } finally { + setLoadingEnv(null) + } + } + + const handleDelete = async (envName: string) => { + // Confirm with user before deleting + const confirmed = window.confirm( + `Delete environment "${envName}"?\n\n` + + `This will:\n` + + `• Stop all containers\n` + + `• Remove the worktree\n` + + `• Close the tmux window\n\n` + + `This action cannot be undone!` + ) + + if (!confirmed) return + + setLoadingEnv(envName) + log(`Deleting environment "${envName}"...`, 'step') + + try { + const result = await tauri.deleteEnvironment(projectRoot, envName) + log(result, 'success') + log(`✓ Environment "${envName}" deleted`, 'success') + + // Refresh discovery to update environment list + await refreshDiscovery() + } catch (err) { + log(`Failed to delete environment: ${err}`, 'error') + } finally { + setLoadingEnv(null) + } + } + + const handleAttachTmux = async (env: UshadowEnvironment) => { + if (!env.path) { + log('Cannot attach tmux: environment has no path', 'error') + return + } + + log(`Opening VS Code with embedded tmux terminal for "${env.name}"...`, 'step') + + try { + await tauri.openInVscodeWithTmux(env.path, env.name) + log(`✓ VS Code opened with tmux in integrated terminal`, 'success') + + // Refresh to update tmux status + await refreshDiscovery(true) + } catch (err) { + log(`Failed to open VS Code with tmux: ${err}`, 'error') + } + } + // New environment handlers const handleNewEnvClone = async (name: string, serverMode: 'dev' | 'prod') => { setShowNewEnvDialog(false) + + // Force lowercase to avoid Docker Compose naming issues + name = name.toLowerCase() + const envPath = `${projectRoot}/../${name}` // Expected clone location const modeLabel = serverMode === 'dev' ? 'hot reload' : 'production' @@ -676,6 +801,10 @@ function App() { const handleNewEnvWorktree = async (name: string, branch: string) => { setShowNewEnvDialog(false) + // Force lowercase to avoid Docker Compose naming issues + name = name.toLowerCase() + branch = branch.toLowerCase() + if (!worktreesDir) { log('Worktrees directory not configured', 'error') return @@ -694,11 +823,24 @@ function App() { await new Promise(r => setTimeout(r, 2000)) log(`[DRY RUN] Worktree environment "${name}" created`, 'success') } else { - // Step 1: Create the git worktree + // Step 1: Create the git worktree with workmux (includes tmux integration) log(`Creating git worktree at ${envPath}...`, 'info') - const worktree = await tauri.createWorktree(projectRoot, worktreesDir, name, branch || undefined) + const worktree = await tauri.createWorktreeWithWorkmux(projectRoot, name, branch || undefined, true) log(`✓ Worktree created at ${worktree.path}`, 'success') + // Check if tmux window was created + try { + const tmuxStatus = await tauri.getEnvironmentTmuxStatus(name) + if (tmuxStatus.exists) { + log(`✓ Tmux window 'ushadow-${name}' created`, 'success') + } else { + log(`⚠ Tmux window not created (tmux may not be running)`, 'warning') + } + } catch (err) { + // Non-critical, don't fail the operation + log(`Could not check tmux status: ${err}`, 'info') + } + // Step 2: Update status setCreatingEnvs(prev => prev.map(e => e.name === name ? { ...e, status: 'starting', path: worktree.path } : e)) @@ -1044,6 +1186,16 @@ function App() { + {/* Tmux Manager */} + + {/* Dev Tools Toggle */} +
+ + +
{/* Tabs */} @@ -112,7 +157,11 @@ export function EnvironmentsPanel({ onStart={() => onStart(env.name)} onStop={() => onStop(env.name)} onOpenInApp={() => onOpenInApp(env)} + onMerge={onMerge ? () => onMerge(env.name) : undefined} + onDelete={onDelete ? () => onDelete(env.name) : undefined} + onAttachTmux={onAttachTmux ? () => onAttachTmux(env) : undefined} isLoading={loadingEnv === env.name} + tmuxStatus={tmuxStatuses[env.name]} /> ))} @@ -129,12 +178,65 @@ export function EnvironmentsPanel({ onStart={() => onStart(env.name)} onStop={() => onStop(env.name)} onOpenInApp={() => onOpenInApp(env)} + onMerge={onMerge ? () => onMerge(env.name) : undefined} + onDelete={onDelete ? () => onDelete(env.name) : undefined} + onAttachTmux={onAttachTmux ? () => onAttachTmux(env) : undefined} isLoading={loadingEnv === env.name} + tmuxStatus={tmuxStatuses[env.name]} /> ))} ) )} + + {/* Tmux Info Dialog */} + {showTmuxInfo && ( +
setShowTmuxInfo(false)} + > +
e.stopPropagation()} + > +
+

+ + Tmux Sessions +

+ +
+
+
+                {tmuxInfo}
+              
+
+
+ {tmuxInfo.includes('No tmux server running') && ( + + )} +
+ +
+
+
+ )}
) } @@ -242,10 +344,16 @@ interface EnvironmentCardProps { onStart: () => void onStop: () => void onOpenInApp: () => void + onMerge?: () => void + onDelete?: () => void + onAttachTmux?: () => void isLoading: boolean + tmuxStatus?: TmuxStatus } -function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading }: EnvironmentCardProps) { +function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, onDelete, onAttachTmux, isLoading, tmuxStatus }: EnvironmentCardProps) { + const [showTmuxWindows, setShowTmuxWindows] = useState(false) + const [tmuxWindows, setTmuxWindows] = useState>([]) const colors = getColors(environment.color || environment.name) const localhostUrl = environment.localhost_url || (environment.backend_port ? `http://localhost:${environment.webui_port || environment.backend_port}` : null) @@ -261,6 +369,42 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading } } + const loadTmuxWindows = async () => { + try { + const sessions = await tauri.getTmuxSessions() + const workmuxSession = sessions.find(s => s.name === 'workmux') + if (workmuxSession) { + // Filter windows for this environment + const envWindowPrefix = `ushadow-${environment.name.toLowerCase()}` + const envWindows = workmuxSession.windows.filter(w => + w.name.toLowerCase().startsWith(envWindowPrefix) + ) + setTmuxWindows(envWindows) + } else { + setTmuxWindows([]) + } + } catch (err) { + console.error('Failed to load tmux windows:', err) + setTmuxWindows([]) + } + } + + const handleToggleTmuxWindows = async () => { + if (!showTmuxWindows) { + await loadTmuxWindows() + } + setShowTmuxWindows(!showTmuxWindows) + } + + const handleKillWindow = async (windowName: string) => { + try { + await tauri.killTmuxWindow(windowName) + await loadTmuxWindows() + } catch (err) { + console.error('Failed to kill window:', err) + } + } + return (
)} + {/* Tmux status badge */} + {tmuxStatus && tmuxStatus.exists && ( + + {getTmuxStatusIcon(tmuxStatus)} {tmuxStatus.current_command || 'tmux'} + + )}
{/* Container tags */} {environment.containers.length > 0 && ( @@ -344,6 +497,34 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, isLoading {/* Top right buttons */}
+ {/* Tmux windows toggle - for worktrees */} + {environment.is_worktree && tmuxStatus && tmuxStatus.exists && ( + + )} + + {/* Tmux attach button - for worktrees without existing tmux */} + {environment.is_worktree && environment.path && onAttachTmux && (!tmuxStatus || !tmuxStatus.exists) && ( + + )} + {/* VS Code button - small */} {environment.path && (
- {/* Start/Stop button - bottom */} -
- {environment.running ? ( - - ) : ( - - )} + {/* Tmux windows list */} + {showTmuxWindows && ( +
+
Tmux Windows:
+ {tmuxWindows.length === 0 ? ( +
No tmux windows found for this environment
+ ) : ( +
+ {tmuxWindows.map((window) => ( +
+
+ {window.index} + + {window.name} + + {window.active && (active)} +
+ +
+ ))} +
+ )} +
+ )} + + {/* Start/Stop and Merge buttons - bottom */} +
+ {/* Merge and Delete buttons - left side, only for worktrees */} +
+ {environment.is_worktree && onMerge && ( + + )} + {environment.is_worktree && onDelete && ( + + )} +
+ + {/* Start/Stop button - right side */} +
+ {environment.running ? ( + + ) : ( + + )} +
) diff --git a/ushadow/launcher/src/components/NewEnvironmentDialog.tsx b/ushadow/launcher/src/components/NewEnvironmentDialog.tsx index 30acbda7..b2d01385 100644 --- a/ushadow/launcher/src/components/NewEnvironmentDialog.tsx +++ b/ushadow/launcher/src/components/NewEnvironmentDialog.tsx @@ -1,14 +1,11 @@ import { useState, useEffect } from 'react' -import { X, Download, FolderOpen, GitBranch, Flame, Package } from 'lucide-react' - -type CreateMode = 'clone' | 'link' | 'worktree' -type ServerMode = 'dev' | 'prod' +import { X, GitBranch } from 'lucide-react' +import { tauri } from '../hooks/useTauri' interface NewEnvironmentDialogProps { isOpen: boolean projectRoot: string onClose: () => void - onClone: (name: string, serverMode: ServerMode) => void onLink: (name: string, path: string) => void onWorktree: (name: string, branch: string) => void } @@ -17,61 +14,136 @@ export function NewEnvironmentDialog({ isOpen, projectRoot, onClose, - onClone, onLink, onWorktree, }: NewEnvironmentDialogProps) { const [name, setName] = useState('') - const [mode, setMode] = useState('clone') - const [serverMode, setServerMode] = useState('dev') - const [linkPath, setLinkPath] = useState('') const [branch, setBranch] = useState('') - - // Set default link path when mode changes to 'link' - useEffect(() => { - if (mode === 'link' && !linkPath && projectRoot) { - // Default to parent directory + /ushadow (sibling to current repo) - const parentDir = projectRoot.split('/').slice(0, -1).join('/') - setLinkPath(parentDir ? `${parentDir}/ushadow` : '') - } - }, [mode, projectRoot, linkPath]) + const [showConflictDialog, setShowConflictDialog] = useState(false) + const [existingWorktree, setExistingWorktree] = useState<{ path: string; name: string } | null>(null) + const [isChecking, setIsChecking] = useState(false) // Reset form when dialog closes useEffect(() => { if (!isOpen) { setName('') - setLinkPath('') setBranch('') - setMode('clone') - setServerMode('dev') + setShowConflictDialog(false) + setExistingWorktree(null) } }, [isOpen]) if (!isOpen) return null - const handleSubmit = () => { + const handleSubmit = async () => { if (!name.trim()) return - - switch (mode) { - case 'clone': - onClone(name.trim(), serverMode) - break - case 'link': - onLink(name.trim(), linkPath.trim()) - break - case 'worktree': - onWorktree(name.trim(), branch.trim() || name.trim()) - break + if (isChecking) return + + const envName = name.trim() + const branchName = branch.trim() || envName + + // Check if worktree exists for this branch + setIsChecking(true) + try { + const existing = await tauri.checkWorktreeExists(projectRoot, branchName) + + if (existing) { + // Worktree exists - show conflict dialog + setExistingWorktree({ path: existing.path, name: existing.name }) + setShowConflictDialog(true) + } else { + // No conflict - create new worktree + onWorktree(envName, branchName) + // Reset form + setName('') + setBranch('') + } + } catch (error) { + console.error('Error checking worktree:', error) + // If check fails, proceed with creation anyway + onWorktree(envName, branchName) + setName('') + setBranch('') + } finally { + setIsChecking(false) } + } - // Reset form + const handleLinkToExisting = () => { + if (!existingWorktree) return + onLink(name.trim(), existingWorktree.path) + setShowConflictDialog(false) + setExistingWorktree(null) setName('') - setLinkPath('') setBranch('') - setServerMode('dev') } - const isValid = name.trim() && (mode !== 'link' || linkPath.trim()) + const handleRemakeWorktree = () => { + onWorktree(name.trim(), branch.trim() || name.trim()) + setShowConflictDialog(false) + setExistingWorktree(null) + setName('') + setBranch('') + } + + const isValid = name.trim() + + // Show conflict dialog if there's an existing worktree + if (showConflictDialog && existingWorktree) { + return ( +
+
+
+

Worktree Already Exists

+ +
+ +

+ A worktree for branch {branch.trim() || name.trim()} already exists at: +

+ +
+ {existingWorktree.path} +
+ +

+ Would you like to link to the existing worktree or remake it? +

+ +
+ + + +
+
+
+ ) + } return (
{/* Header */}
-

New Environment

+

+ + New Environment +

- {/* Mode Selection */} + {/* Branch Name */}
-
- setMode('clone')} - /> - setMode('link')} - /> - setMode('worktree')} - /> -
+ setBranch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && isValid && !isChecking && handleSubmit()} + className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" + placeholder={name || 'feature/my-branch'} + data-testid="branch-input" + /> +

+ Branch names will be converted to lowercase +

- {/* Server Mode - only for clone */} - {mode === 'clone' && ( -
- -
- - -
-
- )} - - {/* Mode-specific inputs */} - {mode === 'link' && ( -
- - setLinkPath(e.target.value)} - className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" - placeholder="/path/to/existing/ushadow" - data-testid="link-path-input" - /> -
- )} - - {mode === 'worktree' && ( -
- - setBranch(e.target.value)} - className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" - placeholder={name || 'feature/my-branch'} - data-testid="branch-input" - /> -
- )} - {/* Helper text */}

- {mode === 'clone' && 'Creates a fresh clone of the repository'} - {mode === 'link' && 'Links to an existing Ushadow folder'} - {mode === 'worktree' && 'Creates a git worktree for parallel development'} + Creates a git worktree for parallel development. If a worktree already exists for this branch, you'll be asked to link or remake it.

{/* Actions */} @@ -230,40 +225,14 @@ export function NewEnvironmentDialog({
) } - -function ModeButton({ - icon: Icon, - label, - active, - onClick, -}: { - icon: typeof Download - label: string - active: boolean - onClick: () => void -}) { - return ( - - ) -} diff --git a/ushadow/launcher/src/components/TmuxManagerDialog.tsx b/ushadow/launcher/src/components/TmuxManagerDialog.tsx new file mode 100644 index 00000000..9d9a9cd0 --- /dev/null +++ b/ushadow/launcher/src/components/TmuxManagerDialog.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect } from 'react' +import { X, Terminal, Trash2, AlertTriangle } from 'lucide-react' +import { tauri, TmuxSessionInfo } from '../hooks/useTauri' + +interface TmuxManagerDialogProps { + isOpen: boolean + onClose: () => void + onRefresh?: () => void +} + +export function TmuxManagerDialog({ isOpen, onClose, onRefresh }: TmuxManagerDialogProps) { + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [confirmKillServer, setConfirmKillServer] = useState(false) + + useEffect(() => { + if (isOpen) { + loadSessions() + } + }, [isOpen]) + + const loadSessions = async () => { + setLoading(true) + setError(null) + try { + const data = await tauri.getTmuxSessions() + setSessions(data) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + const handleKillWindow = async (windowName: string) => { + try { + await tauri.killTmuxWindow(windowName) + await loadSessions() + onRefresh?.() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const handleKillSession = async (sessionName: string) => { + try { + // Kill all windows in the session by killing the session + const session = sessions.find(s => s.name === sessionName) + if (session) { + for (const window of session.windows) { + await tauri.killTmuxWindow(window.name) + } + } + await loadSessions() + onRefresh?.() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const handleKillServer = async () => { + try { + await tauri.killTmuxServer() + setSessions([]) + setConfirmKillServer(false) + onRefresh?.() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setConfirmKillServer(false) + } + } + + if (!isOpen) return null + + return ( +
+
+
+
+ +

Tmux Session Manager

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Confirm kill server dialog */} + {confirmKillServer && ( +
+
+ +
+

+ This will kill ALL tmux sessions and windows. Are you sure? +

+
+ + +
+
+
+
+ )} + +
+ {loading && ( +
+ Loading tmux sessions... +
+ )} + + {!loading && sessions.length === 0 && ( +
+ No tmux sessions found. Create an environment to start one. +
+ )} + + {!loading && sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
+
+
+ +

{session.name}

+ + ({session.window_count} {session.window_count === 1 ? 'window' : 'windows'}) + +
+ +
+ + {session.windows.length === 0 && ( +
No windows in this session
+ )} + +
+ {session.windows.map((window) => ( +
+
+ {window.index} + + {window.name} + + {window.active && ( + (active) + )} + + {window.panes} {window.panes === 1 ? 'pane' : 'panes'} + +
+ +
+ ))} +
+
+ ))} +
+ )} +
+ +
+ +
+ {sessions.length > 0 && ( + + )} + +
+
+
+
+ ) +} diff --git a/ushadow/launcher/src/hooks/useTauri.ts b/ushadow/launcher/src/hooks/useTauri.ts index 5cf9578a..bd79a194 100644 --- a/ushadow/launcher/src/hooks/useTauri.ts +++ b/ushadow/launcher/src/hooks/useTauri.ts @@ -9,11 +9,15 @@ export interface Prerequisites { tailscale_connected: boolean git_installed: boolean python_installed: boolean + workmux_installed: boolean + tmux_installed: boolean homebrew_version: string | null docker_version: string | null tailscale_version: string | null git_version: string | null python_version: string | null + workmux_version: string | null + tmux_version: string | null } export interface UshadowEnvironment { @@ -112,10 +116,28 @@ export const tauri = { // Worktree management listWorktrees: (mainRepo: string) => invoke('list_worktrees', { mainRepo }), + checkWorktreeExists: (mainRepo: string, branch: string) => invoke('check_worktree_exists', { mainRepo, branch }), createWorktree: (mainRepo: string, worktreesDir: string, name: string, baseBranch?: string) => invoke('create_worktree', { mainRepo, worktreesDir, name, baseBranch }), + createWorktreeWithWorkmux: (mainRepo: string, name: string, baseBranch?: string, background?: boolean) => + invoke('create_worktree_with_workmux', { mainRepo, name, baseBranch, background }), + mergeWorktreeWithRebase: (mainRepo: string, name: string, useRebase: boolean, keepWorktree: boolean) => + invoke('merge_worktree_with_rebase', { mainRepo, name, useRebase, keepWorktree }), + listTmuxSessions: () => invoke('list_tmux_sessions'), + getTmuxWindowStatus: (windowName: string) => invoke('get_tmux_window_status', { windowName }), + getEnvironmentTmuxStatus: (envName: string) => invoke('get_environment_tmux_status', { envName }), + getTmuxInfo: () => invoke('get_tmux_info'), + ensureTmuxRunning: () => invoke('ensure_tmux_running'), + attachTmuxToWorktree: (worktreePath: string, envName: string) => invoke('attach_tmux_to_worktree', { worktreePath, envName }), openInVscode: (path: string, envName?: string) => invoke('open_in_vscode', { path, envName }), + openInVscodeWithTmux: (path: string, envName: string) => invoke('open_in_vscode_with_tmux', { path, envName }), removeWorktree: (mainRepo: string, name: string) => invoke('remove_worktree', { mainRepo, name }), + deleteEnvironment: (mainRepo: string, envName: string) => invoke('delete_environment', { mainRepo, envName }), + + // Tmux management + getTmuxSessions: () => invoke('get_tmux_sessions'), + killTmuxWindow: (windowName: string) => invoke('kill_tmux_window', { windowName }), + killTmuxServer: () => invoke('kill_tmux_server'), } // WorktreeInfo type @@ -125,4 +147,28 @@ export interface WorktreeInfo { name: string } +// Tmux status types +export type TmuxActivityStatus = 'Working' | 'Waiting' | 'Done' | 'Error' | 'Unknown' + +export interface TmuxStatus { + exists: boolean + window_name: string | null + current_command: string | null + activity_status: TmuxActivityStatus +} + +// Tmux session management types +export interface TmuxWindowInfo { + name: string + index: string + active: boolean + panes: number +} + +export interface TmuxSessionInfo { + name: string + window_count: number + windows: TmuxWindowInfo[] +} + export default tauri diff --git a/ushadow/launcher/src/hooks/useTmuxMonitoring.ts b/ushadow/launcher/src/hooks/useTmuxMonitoring.ts new file mode 100644 index 00000000..ee4ffec4 --- /dev/null +++ b/ushadow/launcher/src/hooks/useTmuxMonitoring.ts @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react' +import { tauri, TmuxStatus } from './useTauri' + +interface TmuxMonitoringState { + [envName: string]: TmuxStatus +} + +/** + * Hook to monitor tmux status for all environments + * Polls every 3 seconds to detect Claude Code activity and command execution + */ +export function useTmuxMonitoring(environmentNames: string[], enabled: boolean = true) { + const [tmuxStatuses, setTmuxStatuses] = useState({}) + + useEffect(() => { + if (!enabled || environmentNames.length === 0) { + return + } + + const pollTmuxStatuses = async () => { + const statuses: TmuxMonitoringState = {} + + // Poll all environments in parallel + await Promise.all( + environmentNames.map(async (envName) => { + try { + const status = await tauri.getEnvironmentTmuxStatus(envName) + statuses[envName] = status + } catch (error) { + // If tmux monitoring fails, mark as unknown + statuses[envName] = { + exists: false, + window_name: null, + current_command: null, + activity_status: 'Unknown', + } + } + }) + ) + + setTmuxStatuses(statuses) + } + + // Initial poll + pollTmuxStatuses() + + // Poll every 3 seconds + const interval = setInterval(pollTmuxStatuses, 3000) + + return () => clearInterval(interval) + }, [environmentNames, enabled]) + + return tmuxStatuses +} + +/** + * Get status icon for tmux activity + */ +export function getTmuxStatusIcon(status: TmuxStatus | undefined): string { + if (!status || !status.exists) { + return '' + } + + switch (status.activity_status) { + case 'Working': + return '🤖' + case 'Waiting': + return '💬' + case 'Done': + return '✅' + case 'Error': + return '❌' + default: + return '' + } +} + +/** + * Get status text for tmux activity + */ +export function getTmuxStatusText(status: TmuxStatus | undefined): string { + if (!status || !status.exists) { + return '' + } + + const command = status.current_command || 'unknown' + + switch (status.activity_status) { + case 'Working': + return `Running: ${command}` + case 'Waiting': + return 'Shell ready' + case 'Done': + return 'Task complete' + case 'Error': + return 'Command failed' + default: + return '' + } +} From b02b4a1a0edcc6e9772691a8fdde323c496b2ef9 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sat, 17 Jan 2026 20:04:16 +0000 Subject: [PATCH 03/70] Tmux windows and add from branch --- .../backend/src/services/docker_manager.py | 2 +- ushadow/launcher/src-tauri/Cargo.lock | 559 +++++++++++++++++- ushadow/launcher/src-tauri/Cargo.toml | 8 +- .../src-tauri/src/commands/discovery.rs | 132 ++++- .../launcher/src-tauri/src/commands/docker.rs | 19 +- .../launcher/src-tauri/src/commands/mod.rs | 5 + .../src-tauri/src/commands/settings.rs | 139 +++++ .../src-tauri/src/commands/worktree.rs | 506 +++++++++++++--- ushadow/launcher/src-tauri/src/main.rs | 13 +- ushadow/launcher/src-tauri/src/models.rs | 9 + ushadow/launcher/src-tauri/tauri.conf.json | 5 + ushadow/launcher/src/App.tsx | 173 +++++- .../src/components/BranchSelector.tsx | 156 +++++ .../launcher/src/components/EmbeddedView.tsx | 41 +- .../src/components/EnvironmentsPanel.tsx | 310 ++++++---- .../src/components/NewEnvironmentDialog.tsx | 91 ++- .../src/components/PrerequisitesPanel.tsx | 34 +- .../src/components/SettingsDialog.tsx | 194 ++++++ ushadow/launcher/src/hooks/useTauri.ts | 24 + ushadow/launcher/src/index.css | 15 + 20 files changed, 2132 insertions(+), 303 deletions(-) create mode 100644 ushadow/launcher/src-tauri/src/commands/settings.rs create mode 100644 ushadow/launcher/src/components/BranchSelector.tsx create mode 100644 ushadow/launcher/src/components/SettingsDialog.tsx diff --git a/ushadow/backend/src/services/docker_manager.py b/ushadow/backend/src/services/docker_manager.py index 354f387d..a1078d53 100644 --- a/ushadow/backend/src/services/docker_manager.py +++ b/ushadow/backend/src/services/docker_manager.py @@ -1243,7 +1243,7 @@ async def _start_service_via_compose(self, service_name: str, compose_file: str) cwd=str(compose_dir), capture_output=True, text=True, - timeout=60 + timeout=600 # 10 minutes for builds (some services like vibe-kanban compile Rust) ) if result.returncode == 0: diff --git a/ushadow/launcher/src-tauri/Cargo.lock b/ushadow/launcher/src-tauri/Cargo.lock index 46a8af26..cfdb13f8 100644 --- a/ushadow/launcher/src-tauri/Cargo.lock +++ b/ushadow/launcher/src-tauri/Cargo.lock @@ -47,6 +47,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image 0.25.9", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -330,6 +351,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -435,11 +462,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -593,6 +631,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -708,6 +752,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -718,6 +771,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -756,6 +821,12 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dtoa" version = "1.0.11" @@ -855,6 +926,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -882,6 +959,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.113", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -901,6 +998,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.26" @@ -919,6 +1027,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.5" @@ -944,6 +1058,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1180,6 +1300,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1393,12 +1523,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1560,7 +1710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1699,6 +1849,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.0", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1740,6 +1904,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2027,6 +2200,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.7.1" @@ -2072,6 +2254,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2123,6 +2315,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.26.4" @@ -2154,6 +2360,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.11.7" @@ -2243,6 +2458,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2254,6 +2481,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2273,6 +2513,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -2362,6 +2613,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2448,6 +2705,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.12.1", +] + [[package]] name = "phf" version = "0.8.0" @@ -2637,6 +2905,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2651,6 +2932,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2739,6 +3041,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -3258,6 +3575,48 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3321,6 +3680,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3598,7 +3973,7 @@ dependencies = [ "glib", "glib-sys", "gtk", - "image", + "image 0.24.9", "instant", "jni", "lazy_static", @@ -3611,7 +3986,7 @@ dependencies = [ "objc", "once_cell", "parking_lot", - "png", + "png 0.17.16", "raw-window-handle", "scopeguard", "serde", @@ -3737,7 +4112,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "regex", @@ -3793,6 +4168,7 @@ version = "0.14.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d" dependencies = [ + "arboard", "cocoa", "gtk", "percent-encoding", @@ -3883,6 +4259,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thin-slice" version = "0.1.1" @@ -3938,6 +4323,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.44" @@ -4201,6 +4600,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4250,15 +4660,19 @@ dependencies = [ [[package]] name = "ushadow-launcher" -version = "0.4.17" +version = "0.5.1" dependencies = [ + "chrono", + "dirs", "open 5.3.3", + "portable-pty", "reqwest", "serde", "serde_json", "tauri", "tauri-build", "tokio", + "uuid", ] [[package]] @@ -4433,6 +4847,76 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml 0.38.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -4528,6 +5012,12 @@ dependencies = [ "windows-metadata", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5141,6 +5631,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.50.0" @@ -5167,6 +5666,24 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.17", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -5232,6 +5749,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5406,6 +5940,21 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcb2c125bd7365735bebeb420ccb880265ed2d2bddcbcd49f597fdfe6bd5e577" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/ushadow/launcher/src-tauri/Cargo.toml b/ushadow/launcher/src-tauri/Cargo.toml index e01b5ec7..29a38948 100644 --- a/ushadow/launcher/src-tauri/Cargo.toml +++ b/ushadow/launcher/src-tauri/Cargo.toml @@ -11,12 +11,16 @@ edition = "2021" tauri-build = { version = "1", features = [] } [dependencies] -tauri = { version = "1", features = [ "path-all", "process-exit", "shell-execute", "process-relaunch", "shell-open", "process-command-api", "dialog-all", "notification-all", "system-tray"] } +tauri = { version = "1", features = [ "clipboard-all", "path-all", "process-exit", "shell-execute", "process-relaunch", "shell-open", "process-command-api", "dialog-all", "notification-all", "system-tray"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["process", "time", "macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["process", "time", "macros", "rt-multi-thread", "io-util", "sync"] } reqwest = { version = "0.11", features = ["blocking"] } open = "5" +portable-pty = "0.8" +uuid = { version = "1.6", features = ["v4", "serde"] } +dirs = "5" +chrono = "0.4" [features] default = ["custom-protocol"] diff --git a/ushadow/launcher/src-tauri/src/commands/discovery.rs b/ushadow/launcher/src-tauri/src/commands/discovery.rs index d6ecb96f..8b0a645a 100644 --- a/ushadow/launcher/src-tauri/src/commands/discovery.rs +++ b/ushadow/launcher/src-tauri/src/commands/discovery.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::models::{DiscoveryResult, EnvironmentStatus, InfraService, UshadowEnvironment, WorktreeInfo}; use super::prerequisites::{check_docker, check_tailscale}; use super::utils::silent_command; @@ -19,6 +19,8 @@ struct EnvContainerInfo { backend_port: Option, containers: Vec, has_running: bool, + working_dir: Option, + created_at: Option, } // Cache tailscale status for 10 seconds to avoid slow repeated checks @@ -133,6 +135,8 @@ pub async fn discover_environments_with_config( backend_port: None, containers: Vec::new(), has_running: false, + working_dir: None, + created_at: None, }); entry.containers.push(name.to_string()); @@ -149,6 +153,20 @@ pub async fn discover_environments_with_config( } } } + + // Get working directory and creation time from container if we don't have it yet + if name.contains("backend") { + if entry.working_dir.is_none() { + if let Some(wd) = get_container_working_dir(name) { + entry.working_dir = Some(wd); + } + } + if entry.created_at.is_none() { + if let Some(timestamp) = get_container_created_at(name) { + entry.created_at = Some(timestamp); + } + } + } } } } @@ -160,8 +178,11 @@ pub async fn discover_environments_with_config( for (name, wt) in &worktree_map { let (primary, _dark) = get_colors_for_name(name); + // Get creation time from worktree directory + let created_at = get_directory_created_at(&wt.path); + // Check if this environment has Docker containers - let (status, backend_port, webui_port, localhost_url, tailscale_url, tailscale_active, containers) = + let (status, backend_port, webui_port, localhost_url, tailscale_url, tailscale_active, containers, docker_created_at) = if let Some(info) = env_map.remove(name) { let port = info.backend_port.unwrap_or(8000); let wp = if port >= 8000 { Some(port - 5000) } else { None }; @@ -182,12 +203,21 @@ pub async fn discover_environments_with_config( EnvironmentStatus::Stopped }; - (env_status, info.backend_port, wp, url, ts_url, ts_active, info.containers) + (env_status, info.backend_port, wp, url, ts_url, ts_active, info.containers, info.created_at) } else { - (EnvironmentStatus::Available, None, None, None, None, false, Vec::new()) + (EnvironmentStatus::Available, None, None, None, None, false, Vec::new(), None) }; let running = status == EnvironmentStatus::Running || status == EnvironmentStatus::Partial; + + // Use Docker created_at if available and newer than worktree, otherwise use worktree created_at + let final_created_at = match (created_at, docker_created_at) { + (Some(wt_time), Some(docker_time)) => Some(wt_time.min(docker_time)), + (Some(wt_time), None) => Some(wt_time), + (None, Some(docker_time)) => Some(docker_time), + (None, None) => None, + }; + environments.push(UshadowEnvironment { name: name.clone(), color: primary, @@ -202,6 +232,7 @@ pub async fn discover_environments_with_config( tailscale_active, containers, is_worktree: true, + created_at: final_created_at, }); } @@ -231,7 +262,7 @@ pub async fn discover_environments_with_config( environments.push(UshadowEnvironment { name: name.clone(), color: primary, - path: None, + path: info.working_dir, branch: None, status, running, @@ -242,11 +273,19 @@ pub async fn discover_environments_with_config( tailscale_active, containers: info.containers, is_worktree: false, + created_at: info.created_at, }); } - // Sort environments by name - environments.sort_by(|a, b| a.name.cmp(&b.name)); + // Sort environments by creation time (newest first), fallback to name + environments.sort_by(|a, b| { + match (b.created_at, a.created_at) { + (Some(b_time), Some(a_time)) => b_time.cmp(&a_time), // Reverse order (newest first) + (Some(_), None) => std::cmp::Ordering::Less, // b has time, a doesn't - b comes first + (None, Some(_)) => std::cmp::Ordering::Greater, // a has time, b doesn't - a comes first + (None, None) => a.name.cmp(&b.name), // Neither has time, sort by name + } + }); eprintln!("[discovery] Returning {} environments:", environments.len()); for env in &environments { @@ -323,3 +362,82 @@ fn get_tailscale_url(_env_name: &str, port: u16) -> Option { None } + +/// Get working directory from Docker container using docker inspect +/// This allows us to retrieve the path even for containers not started by the launcher +fn get_container_working_dir(container_name: &str) -> Option { + // Use docker inspect to get container details + let output = silent_command("docker") + .args(["inspect", container_name, "--format", "{{.Config.WorkingDir}}"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let working_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Docker returns the working dir inside the container (e.g., "/app") + // We need to map this to the host path using volume mounts + // Try to get the source path from volume mounts + let mount_output = silent_command("docker") + .args(["inspect", container_name, "--format", "{{range .Mounts}}{{if eq .Destination \"/app\"}}{{.Source}}{{end}}{{end}}"]) + .output() + .ok()?; + + if mount_output.status.success() { + let mount_path = String::from_utf8_lossy(&mount_output.stdout).trim().to_string(); + if !mount_path.is_empty() { + return Some(mount_path); + } + } + + // Fallback: if no mount found or working dir is not /app, return None + None +} + +/// Get container creation time from Docker inspect +/// Returns Unix timestamp in seconds +fn get_container_created_at(container_name: &str) -> Option { + let output = silent_command("docker") + .args(["inspect", container_name, "--format", "{{.Created}}"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let created_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Parse RFC3339 timestamp (e.g., "2024-01-17T18:30:45.123456789Z") + // Convert to Unix timestamp + if let Ok(datetime) = chrono::DateTime::parse_from_rfc3339(&created_str) { + return Some(datetime.timestamp()); + } + + None +} + +/// Get directory creation time from filesystem +/// Returns Unix timestamp in seconds +fn get_directory_created_at(path: &str) -> Option { + let metadata = std::fs::metadata(path).ok()?; + + // Try to get creation time (birth time) + if let Ok(created) = metadata.created() { + if let Ok(duration) = created.duration_since(UNIX_EPOCH) { + return Some(duration.as_secs() as i64); + } + } + + // Fallback to modified time if creation time not available + if let Ok(modified) = metadata.modified() { + if let Ok(duration) = modified.duration_since(UNIX_EPOCH) { + return Some(duration.as_secs() as i64); + } + } + + None +} diff --git a/ushadow/launcher/src-tauri/src/commands/docker.rs b/ushadow/launcher/src-tauri/src/commands/docker.rs index 8f75e0a3..c60e6ddb 100644 --- a/ushadow/launcher/src-tauri/src/commands/docker.rs +++ b/ushadow/launcher/src-tauri/src/commands/docker.rs @@ -421,12 +421,13 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env } // Run setup with uv in dev mode with calculated port offset - log_messages.push(format!("Running: {} run --with pyyaml setup/run.py --dev --quick --skip-admin", uv_cmd)); + // Note: Removed --skip-admin flag so admin user can be auto-created from secrets.yaml + log_messages.push(format!("Running: {} run --with pyyaml setup/run.py --dev --quick", uv_cmd)); // Build the full command string for shell execution // Pass PORT_OFFSET for compatibility with both old and new setup scripts let setup_command = format!( - "cd '{}' && ENV_NAME={} PORT_OFFSET={} {} run --with pyyaml setup/run.py --dev --quick --skip-admin", + "cd '{}' && ENV_NAME={} PORT_OFFSET={} {} run --with pyyaml setup/run.py --dev --quick", working_dir, env_name, port_offset, uv_cmd ); @@ -721,7 +722,7 @@ pub fn open_browser(url: String) -> Result<(), String> { -/// Create a new environment using start-dev.sh +/// Create a new environment using dev.sh /// mode: "dev" for hot-reload, "prod" for production build #[tauri::command] pub async fn create_environment(state: State<'_, AppState>, name: String, mode: Option) -> Result { @@ -729,10 +730,10 @@ pub async fn create_environment(state: State<'_, AppState>, name: String, mode: let project_root = root.clone().ok_or("Project root not set")?; drop(root); - // Check if start-dev.sh exists - let script_path = std::path::Path::new(&project_root).join("start-dev.sh"); + // Check if dev.sh exists + let script_path = std::path::Path::new(&project_root).join("dev.sh"); if !script_path.exists() { - return Err(format!("start-dev.sh not found in {}. Make sure you're pointing to a valid Ushadow repository.", project_root)); + return Err(format!("dev.sh not found in {}. Make sure you're pointing to a valid Ushadow repository.", project_root)); } // Find available ports (default: 8000 for backend, 3000 for webui) @@ -747,15 +748,15 @@ pub async fn create_environment(state: State<'_, AppState>, name: String, mode: _ => "--dev", // Default to dev mode (hot-reload) }; - // Run start-dev.sh in quick mode with environment name and port offset + // Run dev.sh in quick mode with environment name and port offset let output = silent_command("bash") - .args(["start-dev.sh", "--quick", mode_flag]) + .args(["dev.sh", "--quick", mode_flag]) .current_dir(&project_root) .env("ENV_NAME", &name) .env("PORT_OFFSET", port_offset.to_string()) .env("USHADOW_NO_BROWSER", "1") // Custom env var we can check in script .output() - .map_err(|e| format!("Failed to run start-dev.sh: {}", e))?; + .map_err(|e| format!("Failed to run dev.sh: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/ushadow/launcher/src-tauri/src/commands/mod.rs b/ushadow/launcher/src-tauri/src/commands/mod.rs index c3f06248..781a4974 100644 --- a/ushadow/launcher/src-tauri/src/commands/mod.rs +++ b/ushadow/launcher/src-tauri/src/commands/mod.rs @@ -4,11 +4,16 @@ mod prerequisites; mod installer; mod utils; mod permissions; +mod settings; pub mod worktree; +// Embedded terminal module (PTY-based) - DEPRECATED in favor of native terminal integration (iTerm2/Terminal.app/gnome-terminal) +// pub mod terminal; pub use docker::*; pub use discovery::*; pub use prerequisites::*; pub use installer::*; pub use permissions::*; +pub use settings::*; pub use worktree::*; +// pub use terminal::*; diff --git a/ushadow/launcher/src-tauri/src/commands/settings.rs b/ushadow/launcher/src-tauri/src/commands/settings.rs new file mode 100644 index 00000000..ceb3f7c1 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/settings.rs @@ -0,0 +1,139 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LauncherSettings { + pub default_admin_email: Option, + pub default_admin_password: Option, + pub default_admin_name: Option, +} + +impl Default for LauncherSettings { + fn default() -> Self { + Self { + default_admin_email: None, + default_admin_password: None, + default_admin_name: Some("Administrator".to_string()), + } + } +} + +/// Get the path to the launcher settings file +fn get_settings_path() -> Result { + let home_dir = dirs::home_dir() + .ok_or("Could not determine home directory")?; + + let config_dir = home_dir.join(".config").join("ushadow-launcher"); + + // Ensure directory exists + if !config_dir.exists() { + fs::create_dir_all(&config_dir) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + Ok(config_dir.join("settings.json")) +} + +/// Load launcher settings from disk +#[tauri::command] +pub async fn load_launcher_settings() -> Result { + let settings_path = get_settings_path()?; + + if !settings_path.exists() { + // Return default settings if file doesn't exist + return Ok(LauncherSettings::default()); + } + + let contents = fs::read_to_string(&settings_path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + + let settings: LauncherSettings = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse settings: {}", e))?; + + Ok(settings) +} + +/// Save launcher settings to disk +#[tauri::command] +pub async fn save_launcher_settings(settings: LauncherSettings) -> Result<(), String> { + let settings_path = get_settings_path()?; + + let json = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + fs::write(&settings_path, json) + .map_err(|e| format!("Failed to write settings: {}", e))?; + + eprintln!("[save_launcher_settings] Saved settings to: {}", settings_path.display()); + + Ok(()) +} + +/// Write admin credentials to a worktree's secrets.yaml file +#[tauri::command] +pub async fn write_credentials_to_worktree( + worktree_path: String, + admin_email: String, + admin_password: String, + admin_name: Option, +) -> Result<(), String> { + use std::path::Path; + + let secrets_dir = Path::new(&worktree_path).join("config").join("SECRETS"); + let secrets_file = secrets_dir.join("secrets.yaml"); + + // Ensure SECRETS directory exists + if !secrets_dir.exists() { + fs::create_dir_all(&secrets_dir) + .map_err(|e| format!("Failed to create SECRETS directory: {}", e))?; + } + + // Read existing secrets.yaml or create new one + let mut content = if secrets_file.exists() { + fs::read_to_string(&secrets_file) + .map_err(|e| format!("Failed to read secrets.yaml: {}", e))? + } else { + String::new() + }; + + // Parse YAML to check if admin section exists + // For simplicity, we'll do basic string manipulation + // If the file is empty or doesn't have admin section, add it + + let admin_section = format!( + r#"admin: + name: "{}" + email: "{}" + password: "{}" +"#, + admin_name.unwrap_or_else(|| "Administrator".to_string()), + admin_email, + admin_password + ); + + // Check if admin section exists + if content.contains("admin:") { + eprintln!("[write_credentials_to_worktree] Admin section already exists in secrets.yaml, skipping"); + return Ok(()); + } + + // If file is empty or doesn't have admin section, add it + if content.trim().is_empty() { + content = admin_section; + } else { + // Append admin section + if !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&admin_section); + } + + // Write updated content + fs::write(&secrets_file, content) + .map_err(|e| format!("Failed to write secrets.yaml: {}", e))?; + + eprintln!("[write_credentials_to_worktree] Wrote admin credentials to: {}", secrets_file.display()); + + Ok(()) +} diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index 29e3e6e4..87c7e4f8 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -1,4 +1,4 @@ -use crate::models::{WorktreeInfo, TmuxSessionInfo, TmuxWindowInfo}; +use crate::models::{WorktreeInfo, TmuxSessionInfo, TmuxWindowInfo, ClaudeStatus}; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; @@ -171,6 +171,41 @@ pub async fn list_worktrees(main_repo: String) -> Result, Stri Ok(worktrees) } +/// List all git branches in a repository +#[tauri::command] +pub async fn list_git_branches(main_repo: String) -> Result, String> { + let output = Command::new("git") + .args(["branch", "-a", "--format=%(refname:short)"]) + .current_dir(&main_repo) + .output() + .map_err(|e| format!("Failed to list branches: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Git command failed: {}", stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let branches: Vec = stdout + .lines() + .map(|line| { + // Remove "origin/" prefix from remote branches + line.trim() + .strip_prefix("origin/") + .unwrap_or(line.trim()) + .to_string() + }) + .filter(|b| !b.is_empty() && b != "HEAD" && !b.contains("->")) + .collect(); + + // Deduplicate branches (local and remote may have same name) + let mut unique_branches: Vec = branches.into_iter().collect(); + unique_branches.sort(); + unique_branches.dedup(); + + Ok(unique_branches) +} + /// Create a new git worktree #[tauri::command] pub async fn create_worktree( @@ -656,14 +691,30 @@ pub async fn delete_environment(main_repo: String, env_name: String) -> Result { - messages.push(format!("✓ Removed worktree '{}'", env_name)); + // Step 3: Remove the worktree (if it exists) + eprintln!("[delete_environment] Checking if worktree '{}' exists...", env_name); + match check_worktree_exists(main_repo.clone(), env_name.clone()).await { + Ok(Some(_)) => { + // Worktree exists, remove it + eprintln!("[delete_environment] Removing worktree '{}'...", env_name); + match remove_worktree(main_repo, env_name.clone()).await { + Ok(_) => { + messages.push(format!("✓ Removed worktree '{}'", env_name)); + } + Err(e) => { + return Err(format!("Failed to remove worktree: {}", e)); + } + } + } + Ok(None) => { + // Worktree doesn't exist, skip removal + eprintln!("[delete_environment] No worktree found for '{}', skipping removal", env_name); + messages.push(format!("• No worktree to remove for '{}'", env_name)); } Err(e) => { - return Err(format!("Failed to remove worktree: {}", e)); + // Error checking worktree, log but don't fail + eprintln!("[delete_environment] Warning: Could not check worktree existence: {}", e); + messages.push(format!("⚠ Could not check for worktree")); } } @@ -685,7 +736,21 @@ pub async fn create_worktree_with_workmux( eprintln!("[create_worktree_with_workmux] Creating worktree '{}' from branch '{:?}'", name, base_branch); - // Check if tmux is running - if not, auto-start it + // Use the launcher's own worktree creation logic instead of workmux + // This ensures consistent directory structure + let main_repo_path = PathBuf::from(&main_repo); + let worktrees_dir = main_repo_path.parent() + .ok_or("Could not determine worktrees directory")? + .to_string_lossy() + .to_string(); + + // Create the worktree directly + let worktree = create_worktree(main_repo.clone(), worktrees_dir, name.clone(), base_branch).await?; + + eprintln!("[create_worktree_with_workmux] Worktree created at: {}", worktree.path); + + // Now attach tmux to the worktree + // Ensure tmux is running let tmux_check = shell_command("tmux list-sessions") .output(); @@ -693,97 +758,34 @@ pub async fn create_worktree_with_workmux( if !tmux_available { eprintln!("[create_worktree_with_workmux] tmux not running, attempting to start a new session"); - - // Try to start a new detached tmux session - let start_tmux = shell_command("tmux new-session -d -s workmux") + let _start_tmux = shell_command("tmux new-session -d -s workmux") .output(); - - match start_tmux { - Ok(output) if output.status.success() => { - eprintln!("[create_worktree_with_workmux] Successfully started tmux session 'workmux'"); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - // Session might already exist, which is fine - if !stderr.contains("duplicate session") { - eprintln!("[create_worktree_with_workmux] Failed to start tmux: {}", stderr); - eprintln!("[create_worktree_with_workmux] Falling back to regular git worktree"); - let main_repo_path = PathBuf::from(&main_repo); - let worktrees_dir = main_repo_path.parent() - .ok_or("Could not determine worktrees directory")? - .to_string_lossy() - .to_string(); - - return create_worktree(main_repo, worktrees_dir, name, base_branch).await; - } - } - Err(e) => { - eprintln!("[create_worktree_with_workmux] Failed to start tmux: {}", e); - eprintln!("[create_worktree_with_workmux] Falling back to regular git worktree"); - let main_repo_path = PathBuf::from(&main_repo); - let worktrees_dir = main_repo_path.parent() - .ok_or("Could not determine worktrees directory")? - .to_string_lossy() - .to_string(); - - return create_worktree(main_repo, worktrees_dir, name, base_branch).await; - } - } } - // Build workmux add command - let mut cmd_parts = vec!["workmux", "add"]; - - if background.unwrap_or(false) { - cmd_parts.push("--background"); - } - - // Skip hooks and pane commands for faster creation (we don't need agent prompts) - cmd_parts.extend(&["--no-hooks", "--no-pane-cmds"]); - - if let Some(ref branch) = base_branch { - cmd_parts.push("--base"); - cmd_parts.push(branch); - } - - cmd_parts.push(&name); - - let workmux_cmd = cmd_parts.join(" "); - eprintln!("[create_worktree_with_workmux] Running: {}", workmux_cmd); - - // Execute workmux add - let output = shell_command(&workmux_cmd) - .current_dir(&main_repo) - .output() - .map_err(|e| format!("Failed to run workmux: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - - // If workmux fails because tmux isn't running, fall back - if stderr.contains("tmux is not running") || stderr.contains("tmux") { - eprintln!("[create_worktree_with_workmux] Workmux failed (tmux issue), falling back to regular git worktree"); - let main_repo_path = PathBuf::from(&main_repo); - let worktrees_dir = main_repo_path.parent() - .ok_or("Could not determine worktrees directory")? - .to_string_lossy() - .to_string(); + // Create tmux window for the worktree + let window_name = format!("ushadow-{}", name); + let create_window = shell_command(&format!( + "tmux new-window -t workmux -n {} -c '{}'", + window_name, worktree.path + )) + .output(); - return create_worktree(main_repo, worktrees_dir, name, base_branch).await; + match create_window { + Ok(output) if output.status.success() => { + eprintln!("[create_worktree_with_workmux] Created tmux window '{}'", window_name); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[create_worktree_with_workmux] Warning: Failed to create tmux window: {}", stderr); + // Don't fail the whole operation if tmux fails + } + Err(e) => { + eprintln!("[create_worktree_with_workmux] Warning: Failed to create tmux window: {}", e); + // Don't fail the whole operation if tmux fails } - - return Err(format!("Workmux command failed:\nstdout: {}\nstderr: {}", stdout, stderr)); } - eprintln!("[create_worktree_with_workmux] Workmux add completed successfully"); - - // Get worktree info from git - let worktrees = list_worktrees(main_repo).await?; - worktrees.iter() - .find(|wt| wt.name == name) - .cloned() - .ok_or_else(|| format!("Worktree '{}' was created but not found in list", name)) + Ok(worktree) } /// Merge a worktree branch and clean up using workmux @@ -1024,7 +1026,12 @@ pub async fn get_environment_tmux_status(env_name: String) -> Result Result { Ok("Killed tmux server".to_string()) } + +/// Open a tmux window in iTerm2 (falls back to Terminal.app if not available) +#[tauri::command] +pub async fn open_tmux_in_terminal(window_name: String, worktree_path: String) -> Result { + eprintln!("[open_tmux_in_terminal] Opening tmux window: {} at path: {}", window_name, worktree_path); + + #[cfg(target_os = "macos")] + { + // Check if iTerm2 is installed and available + let iterm_check = Command::new("osascript") + .arg("-e") + .arg("application \"iTerm\" is running") + .output(); + + let iterm_available = iterm_check + .map(|o| o.status.success()) + .unwrap_or(false); + + if iterm_available { + eprintln!("[open_tmux_in_terminal] Using iTerm2"); + + // Extract env name from window name (format: "ushadow-{env}") + let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + let display_name = format!("Ushadow: {}", env_name); + + // Map env names to RGB colors (0-255) + let (r, g, b) = match env_name { + "gold" | "yellow" => (255, 215, 0), // Gold/Yellow + "green" => (0, 255, 0), // Green + "red" => (255, 0, 0), // Red + "purple" | "pink" => (255, 0, 255), // Purple/Magenta + "orange" => (255, 165, 0), // Orange + "blue" => (0, 0, 255), // Blue + "cyan" => (0, 255, 255), // Cyan + "brown" => (165, 42, 42), // Brown + _ => (128, 128, 128), // Gray for unknown + }; + + // Create temp script file with color sequences to avoid quote escaping hell + use std::fs; + let temp_script = format!("/tmp/ushadow_iterm_{}.sh", window_name.replace("/", "_")); + + let script_content = format!( + "#!/bin/bash\nprintf '\\033]0;{}\\007\\033]6;1;bg;red;brightness;{}\\007\\033]6;1;bg;green;brightness;{}\\007\\033]6;1;bg;blue;brightness;{}\\007'\n# Create dedicated session for this environment if it doesn't exist\ntmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'\n# Attach to this environment's dedicated session\nexec tmux attach-session -t {}\n", + display_name, + r, g, b, + window_name, + window_name, + worktree_path, + window_name + ); + fs::write(&temp_script, script_content) + .map_err(|e| format!("Failed to write temp script: {}", e))?; + + shell_command(&format!("chmod +x {}", temp_script)) + .output() + .map_err(|e| format!("Failed to chmod: {}", e))?; + + // Simple iTerm2 AppleScript that executes the script + let applescript = format!( + r#"tell application "iTerm" + activate + set newWindow to (create window with default profile) + tell current session of newWindow + set name to "{}" + write text "{} && exit" + end tell +end tell"#, + display_name, + temp_script + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(&applescript) + .output() + .map_err(|e| format!("Failed to run iTerm2 AppleScript: {}", e))?; + + if output.status.success() { + eprintln!("[open_tmux_in_terminal] iTerm2 success"); + return Ok(format!("Opened tmux window '{}' in iTerm2", window_name)); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("[open_tmux_in_terminal] iTerm2 failed: {}, falling back to Terminal.app", stderr); + } + } else { + eprintln!("[open_tmux_in_terminal] iTerm2 not available, using Terminal.app"); + } + + // Fallback to Terminal.app (macOS default terminal) + let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + let display_name = format!("Ushadow: {}", env_name); + + // Create temp script for Terminal.app (simpler than iTerm2, no tab colors) + use std::fs; + let temp_script = format!("/tmp/ushadow_terminal_{}.sh", window_name.replace("/", "_")); + let script_content = format!( + "#!/bin/bash\nprintf '\\033]0;{}\\007'\n# Create dedicated session for this environment if it doesn't exist\ntmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'\n# Attach to this environment's dedicated session\nexec tmux attach-session -t {}\n", + display_name, + window_name, + window_name, + worktree_path, + window_name + ); + fs::write(&temp_script, script_content) + .map_err(|e| format!("Failed to write temp script: {}", e))?; + + shell_command(&format!("chmod +x {}", temp_script)) + .output() + .map_err(|e| format!("Failed to chmod: {}", e))?; + + let applescript = format!( + r#"tell application "Terminal" + activate + do script "{}" +end tell"#, + temp_script + ); + + let output = Command::new("osascript") + .arg("-e") + .arg(&applescript) + .output() + .map_err(|e| format!("Failed to run AppleScript: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to open Terminal: {}", stderr)); + } + + eprintln!("[open_tmux_in_terminal] Terminal.app success"); + Ok(format!("Opened tmux window '{}' in Terminal.app", window_name)) + } + + #[cfg(not(target_os = "macos"))] + { + // For Linux/Windows - create dedicated tmux session per environment + // Try common terminal emulators in order of preference + let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + + // Create the tmux session if it doesn't exist + let _ = shell_command(&format!( + "tmux has-session -t {} 2>/dev/null || tmux new-session -d -s {} -c '{}'", + window_name, window_name, worktree_path + )).output(); + + // Try different terminal emulators + let terminals = vec![ + ("gnome-terminal", vec!["--", "tmux", "attach-session", "-t", &window_name]), + ("konsole", vec!["-e", "tmux", "attach-session", "-t", &window_name]), + ("xfce4-terminal", vec!["-e", &format!("tmux attach-session -t {}", window_name)]), + ("xterm", vec!["-e", &format!("tmux attach-session -t {}", window_name)]), + ]; + + for (terminal, args) in terminals { + if let Ok(_) = Command::new(terminal).args(&args).spawn() { + eprintln!("[open_tmux_in_terminal] Opened {} for session {}", terminal, window_name); + return Ok(format!("Opened tmux session '{}' in {}", window_name, terminal)); + } + } + + Err("No supported terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal, or xterm.".to_string()) + } +} + +/// Capture the visible content of a tmux pane +#[tauri::command] +pub async fn capture_tmux_pane(window_name: String) -> Result { + // Capture the last 100 lines from the pane + let output = shell_command(&format!( + "tmux capture-pane -t {} -p -S -100", + window_name + )) + .output() + .map_err(|e| format!("Failed to capture pane: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to capture pane: {}", stderr)); + } + + let content = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(content) +} + +/// Get Claude Code status from a tmux window +#[tauri::command] +pub async fn get_claude_status(window_name: String) -> Result { + // First check if the window exists + let status = get_environment_tmux_status(window_name.clone()).await?; + + if !status.exists { + return Ok(ClaudeStatus { + is_running: false, + current_task: None, + last_output: None, + }); + } + + // Always capture the pane content to check for Claude patterns + // (Claude might be running inside a shell, so pane_current_command shows "zsh" not "claude") + let pane_content = capture_tmux_pane(window_name).await?; + + // Check if the output contains Claude-specific patterns + let is_claude_running = pane_content.contains("Claude Code") + || pane_content.contains("claude-code") + || pane_content.contains("╭─ You") + || pane_content.contains("╭─ Claude") + || pane_content.contains("╰─") + || pane_content.contains("★ Insight") + || pane_content.contains("antml:function_calls") + || pane_content.contains(" = pane_content.lines().collect(); + let last_output = if lines.len() > 5 { + lines[lines.len() - 5..].join("\n") + } else { + pane_content.clone() + }; + + Ok(ClaudeStatus { + is_running: true, + current_task, + last_output: Some(last_output), + }) +} + +/// Parse Claude Code output to extract current task +fn parse_claude_task(output: &str) -> Option { + // Look for common Claude Code patterns + let lines: Vec<&str> = output.lines().collect(); + + eprintln!("[parse_claude_task] Parsing {} lines of output", lines.len()); + + // Print last few lines for debugging + for line in lines.iter().rev().take(5) { + eprintln!("[parse_claude_task] Recent line: {}", line); + } + + // Look for the most recent non-empty, meaningful line + for line in lines.iter().rev() { + let trimmed = line.trim(); + + // Skip empty lines, shell prompts, and very short lines + if trimmed.is_empty() + || trimmed.starts_with("$") + || trimmed.starts_with("#") + || trimmed.starts_with(">") + || trimmed.contains("───") + || trimmed.len() < 15 { + continue; + } + + // Look for Claude's action indicators + if trimmed.contains("I'll") + || trimmed.contains("I'm") + || trimmed.contains("I am") + || trimmed.contains("Let me") + || trimmed.contains("I will") { + if trimmed.len() < 200 { + eprintln!("[parse_claude_task] Found action: {}", trimmed); + return Some(trimmed.to_string()); + } + } + + // Look for tool usage + if (trimmed.contains("Using") || trimmed.contains("Running") || trimmed.contains("Calling")) + && trimmed.len() < 150 { + eprintln!("[parse_claude_task] Found tool usage: {}", trimmed); + return Some(trimmed.to_string()); + } + + // Look for file operations (but skip generic commands) + if (trimmed.contains("Reading") || trimmed.contains("Writing") || trimmed.contains("Editing")) + && (trimmed.contains(".") || trimmed.contains("/")) + && trimmed.len() < 150 { + eprintln!("[parse_claude_task] Found file op: {}", trimmed); + return Some(trimmed.to_string()); + } + } + + // Look for "You:" prompt to show last user request + for line in lines.iter().rev() { + if line.trim().starts_with("You:") { + let text = line.trim().strip_prefix("You:")?.trim(); + if !text.is_empty() && text.len() < 150 { + eprintln!("[parse_claude_task] Found user prompt: {}", text); + return Some(format!("💬 {}", text)); + } + } + } + + eprintln!("[parse_claude_task] No meaningful task found"); + None +} diff --git a/ushadow/launcher/src-tauri/src/main.rs b/ushadow/launcher/src-tauri/src/main.rs index 60dba83c..a01bcff9 100644 --- a/ushadow/launcher/src-tauri/src/main.rs +++ b/ushadow/launcher/src-tauri/src/main.rs @@ -21,11 +21,14 @@ use commands::{AppState, check_prerequisites, discover_environments, get_os_type get_default_project_dir, check_project_dir, clone_ushadow_repo, update_ushadow_repo, install_git_windows, install_git_macos, // Worktree commands - list_worktrees, check_worktree_exists, create_worktree, create_worktree_with_workmux, + list_worktrees, list_git_branches, check_worktree_exists, create_worktree, create_worktree_with_workmux, merge_worktree_with_rebase, list_tmux_sessions, get_tmux_window_status, get_environment_tmux_status, get_tmux_info, ensure_tmux_running, attach_tmux_to_worktree, open_in_vscode, open_in_vscode_with_tmux, remove_worktree, delete_environment, get_tmux_sessions, kill_tmux_window, kill_tmux_server, + open_tmux_in_terminal, capture_tmux_pane, get_claude_status, + // Settings + load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, // Permissions check_install_path}; use tauri::{ @@ -146,6 +149,7 @@ fn main() { // Worktree management discover_environments_with_config, list_worktrees, + list_git_branches, check_worktree_exists, create_worktree, create_worktree_with_workmux, @@ -163,6 +167,13 @@ fn main() { get_tmux_sessions, kill_tmux_window, kill_tmux_server, + open_tmux_in_terminal, + capture_tmux_pane, + get_claude_status, + // Settings + load_launcher_settings, + save_launcher_settings, + write_credentials_to_worktree, ]) .setup(|app| { let window = app.get_window("main").unwrap(); diff --git a/ushadow/launcher/src-tauri/src/models.rs b/ushadow/launcher/src-tauri/src/models.rs index cfea78b9..da63c846 100644 --- a/ushadow/launcher/src-tauri/src/models.rs +++ b/ushadow/launcher/src-tauri/src/models.rs @@ -78,6 +78,7 @@ pub struct UshadowEnvironment { pub tailscale_active: bool, pub containers: Vec, pub is_worktree: bool, // True if this environment is a git worktree + pub created_at: Option, // Unix timestamp (seconds since epoch) } /// Infrastructure service status @@ -131,3 +132,11 @@ pub struct TmuxWindowInfo { pub active: bool, pub panes: usize, } + +/// Claude Code status from tmux +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ClaudeStatus { + pub is_running: bool, + pub current_task: Option, + pub last_output: Option, +} diff --git a/ushadow/launcher/src-tauri/tauri.conf.json b/ushadow/launcher/src-tauri/tauri.conf.json index 8add0751..052b4464 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -13,6 +13,11 @@ "tauri": { "allowlist": { "all": false, + "clipboard": { + "all": true, + "writeText": true, + "readText": true + }, "shell": { "all": false, "open": true, diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index 35cfa751..c353f519 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -3,6 +3,7 @@ import { tauri, type Prerequisites, type Discovery, type UshadowEnvironment } fr import { useAppStore } from './store/appStore' import { useWindowFocus } from './hooks/useWindowFocus' import { useTmuxMonitoring } from './hooks/useTmuxMonitoring' +import { writeText, readText } from '@tauri-apps/api/clipboard' import { DevToolsPanel } from './components/DevToolsPanel' import { PrerequisitesPanel } from './components/PrerequisitesPanel' import { InfrastructurePanel } from './components/InfrastructurePanel' @@ -12,8 +13,9 @@ import { LogPanel, type LogEntry, type LogLevel } from './components/LogPanel' import { ProjectSetupDialog } from './components/ProjectSetupDialog' import { NewEnvironmentDialog } from './components/NewEnvironmentDialog' import { TmuxManagerDialog } from './components/TmuxManagerDialog' +import { SettingsDialog } from './components/SettingsDialog' import { EmbeddedView } from './components/EmbeddedView' -import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal } from 'lucide-react' +import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders } from 'lucide-react' import { getColors } from './utils/colors' function App() { @@ -45,8 +47,9 @@ function App() { const [showProjectDialog, setShowProjectDialog] = useState(false) const [showNewEnvDialog, setShowNewEnvDialog] = useState(false) const [showTmuxManager, setShowTmuxManager] = useState(false) + const [showSettingsDialog, setShowSettingsDialog] = useState(false) const [logExpanded, setLogExpanded] = useState(true) - const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string } | null>(null) + const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string; envPath: string | null } | null>(null) const [creatingEnvs, setCreatingEnvs] = useState<{ name: string; status: 'cloning' | 'starting' | 'error'; path?: string; error?: string }[]>([]) const [shouldAutoLaunch, setShouldAutoLaunch] = useState(false) const [leftColumnWidth, setLeftColumnWidth] = useState(350) // pixels @@ -114,6 +117,87 @@ function App() { } }, [isResizing, handleMouseMove, handleMouseUp]) + // Enable keyboard shortcuts for copy/paste + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + const modifier = isMac ? e.metaKey : e.ctrlKey + + // Only handle copy/paste/cut if modifier key is pressed + if (!modifier) return + + // Get the active element + const target = e.target as HTMLElement + const isInputField = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable + + try { + if (e.key.toLowerCase() === 'c') { + // Copy: get selection from input field or window selection + let textToCopy = '' + if (isInputField && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) { + const start = target.selectionStart || 0 + const end = target.selectionEnd || 0 + textToCopy = target.value.substring(start, end) + } else { + const selection = window.getSelection() + textToCopy = selection?.toString() || '' + } + + if (textToCopy) { + await writeText(textToCopy) + e.preventDefault() + } + } else if (e.key.toLowerCase() === 'v') { + // Paste: handle input fields specially, but allow pasting anywhere + const text = await readText() + if (!text) return + + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + // Paste into input/textarea + const start = target.selectionStart || 0 + const end = target.selectionEnd || 0 + const currentValue = target.value + target.value = currentValue.substring(0, start) + text + currentValue.substring(end) + target.selectionStart = target.selectionEnd = start + text.length + + // Trigger input event so React state updates + const event = new Event('input', { bubbles: true }) + target.dispatchEvent(event) + e.preventDefault() + } else if (target.isContentEditable) { + // Paste into contenteditable + document.execCommand('insertText', false, text) + e.preventDefault() + } + } else if (e.key.toLowerCase() === 'x') { + // Cut: only from input fields + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const start = target.selectionStart || 0 + const end = target.selectionEnd || 0 + const selectedText = target.value.substring(start, end) + if (selectedText) { + await writeText(selectedText) + const currentValue = target.value + target.value = currentValue.substring(0, start) + currentValue.substring(end) + target.selectionStart = target.selectionEnd = start + + // Trigger input event so React state updates + const event = new Event('input', { bubbles: true }) + target.dispatchEvent(event) + e.preventDefault() + } + } + } + } catch (err) { + // Silently fail if clipboard access is denied + console.warn('Clipboard access failed:', err) + } + } + + document.addEventListener('keydown', handleKeyDown, true) // Use capture phase + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, []) + // Apply spoofed values to prerequisites const getEffectivePrereqs = useCallback((real: Prerequisites | null): Prerequisites | null => { if (!real) return null @@ -620,11 +704,11 @@ function App() { } } - const handleOpenInApp = (env: { name: string; color?: string; localhost_url: string | null; webui_port: number | null; backend_port: number | null }) => { + const handleOpenInApp = (env: { name: string; color?: string; localhost_url: string | null; webui_port: number | null; backend_port: number | null; path: string | null }) => { const url = env.localhost_url || `http://localhost:${env.webui_port || env.backend_port}` const colors = getColors(env.color || env.name) log(`Opening ${env.name} in embedded view...`, 'info') - setEmbeddedView({ url, envName: env.name, envColor: colors.primary }) + setEmbeddedView({ url, envName: env.name, envColor: colors.primary, envPath: env.path }) } const handleMerge = async (envName: string) => { @@ -673,15 +757,25 @@ function App() { } const handleDelete = async (envName: string) => { + // Find the environment to check if it's a worktree + const env = discovery?.environments.find(e => e.name === envName) + const isWorktree = env?.is_worktree || false + // Confirm with user before deleting - const confirmed = window.confirm( - `Delete environment "${envName}"?\n\n` + - `This will:\n` + - `• Stop all containers\n` + - `• Remove the worktree\n` + - `• Close the tmux window\n\n` + - `This action cannot be undone!` - ) + const message = isWorktree + ? `Delete environment "${envName}"?\n\n` + + `This will:\n` + + `• Stop all containers\n` + + `• Remove the worktree\n` + + `• Close the tmux session\n\n` + + `This action cannot be undone!` + : `Delete environment "${envName}"?\n\n` + + `This will:\n` + + `• Stop all containers\n` + + `• Close the tmux session\n\n` + + `This action cannot be undone!` + + const confirmed = window.confirm(message) if (!confirmed) return @@ -828,6 +922,26 @@ function App() { const worktree = await tauri.createWorktreeWithWorkmux(projectRoot, name, branch || undefined, true) log(`✓ Worktree created at ${worktree.path}`, 'success') + // Step 1.5: Write default admin credentials if configured + try { + const settings = await tauri.loadLauncherSettings() + if (settings.default_admin_email && settings.default_admin_password) { + log(`Writing admin credentials to secrets.yaml...`, 'info') + await tauri.writeCredentialsToWorktree( + worktree.path, + settings.default_admin_email, + settings.default_admin_password, + settings.default_admin_name || undefined + ) + log(`✓ Admin credentials configured`, 'success') + } else { + log(`⚠ No default credentials configured - you'll need to register a user`, 'warning') + } + } catch (err) { + // Non-critical, user can still register manually + log(`Could not write credentials: ${err}`, 'warning') + } + // Check if tmux window was created try { const tmuxStatus = await tauri.getEnvironmentTmuxStatus(name) @@ -1135,7 +1249,7 @@ function App() { return (
@@ -1145,6 +1259,7 @@ function App() { url={embeddedView.url} envName={embeddedView.envName} envColor={embeddedView.envColor} + envPath={embeddedView.envPath} onClose={() => setEmbeddedView(null)} /> )} @@ -1196,16 +1311,15 @@ function App() { - {/* Dev Tools Toggle */} + {/* Settings / Credentials Button */} {/* Refresh */} @@ -1220,13 +1334,6 @@ function App() {
- {/* Dev Tools Panel */} - {showDevTools && ( -
- -
- )} - {/* Main Content */}
{appMode === 'quick' ? ( @@ -1299,7 +1406,11 @@ function App() { installingItem={installingItem} onInstall={handleInstall} onStartDocker={handleStartDocker} + showDevTools={showDevTools} + onToggleDevTools={() => setShowDevTools(!showDevTools)} /> + {/* Dev Tools Panel - appears below Prerequisites */} + {showDevTools && } setShowTmuxManager(false)} onRefresh={() => refreshDiscovery(true)} /> + + {/* Settings Dialog */} + setShowSettingsDialog(false)} + /> ) } diff --git a/ushadow/launcher/src/components/BranchSelector.tsx b/ushadow/launcher/src/components/BranchSelector.tsx new file mode 100644 index 00000000..6120fb7f --- /dev/null +++ b/ushadow/launcher/src/components/BranchSelector.tsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect, useRef } from 'react' +import { ChevronDown, GitBranch, Sparkles } from 'lucide-react' + +interface BranchSelectorProps { + branches: string[] + value: string + onChange: (value: string) => void + placeholder?: string + testId?: string +} + +/** + * Branch selector with autocomplete dropdown + * Highlights Claude-created branches (starting with "claude/") + */ +export function BranchSelector({ branches, value, onChange, placeholder = 'Type or select branch...', testId = 'branch-selector' }: BranchSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [filteredBranches, setFilteredBranches] = useState(branches) + const inputRef = useRef(null) + const dropdownRef = useRef(null) + + // Filter branches based on input value + useEffect(() => { + if (value.trim() === '') { + setFilteredBranches(branches) + } else { + const lowerValue = value.toLowerCase() + setFilteredBranches( + branches.filter(branch => + branch.toLowerCase().includes(lowerValue) + ) + ) + } + }, [value, branches]) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value) + setIsOpen(true) + } + + const handleSelectBranch = (branch: string) => { + onChange(branch) + setIsOpen(false) + inputRef.current?.blur() + } + + const handleInputFocus = () => { + setIsOpen(true) + } + + const isClaudeBranch = (branch: string) => { + return branch.startsWith('claude/') + } + + // Sort branches: Claude branches first, then alphabetically + const sortedBranches = [...filteredBranches].sort((a, b) => { + const aIsClaude = isClaudeBranch(a) + const bIsClaude = isClaudeBranch(b) + + if (aIsClaude && !bIsClaude) return -1 + if (!aIsClaude && bIsClaude) return 1 + return a.localeCompare(b) + }) + + return ( +
+
+ + +
+ + {isOpen && sortedBranches.length > 0 && ( +
+ {sortedBranches.map((branch) => { + const isClaude = isClaudeBranch(branch) + return ( + + ) + })} +
+ )} + + {isOpen && filteredBranches.length === 0 && value.trim() !== '' && ( +
+

+ No branches match "{value}". Press Enter to create it. +

+
+ )} +
+ ) +} diff --git a/ushadow/launcher/src/components/EmbeddedView.tsx b/ushadow/launcher/src/components/EmbeddedView.tsx index eb11ccd6..a28c8036 100644 --- a/ushadow/launcher/src/components/EmbeddedView.tsx +++ b/ushadow/launcher/src/components/EmbeddedView.tsx @@ -1,14 +1,15 @@ -import { X, ExternalLink, RefreshCw, ArrowLeft } from 'lucide-react' +import { X, ExternalLink, RefreshCw, ArrowLeft, Terminal } from 'lucide-react' import { tauri } from '../hooks/useTauri' interface EmbeddedViewProps { url: string envName: string envColor?: string + envPath: string | null onClose: () => void } -export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewProps) { +export function EmbeddedView({ url, envName, envColor, envPath, onClose }: EmbeddedViewProps) { const handleOpenExternal = () => { tauri.openBrowser(url) } @@ -20,6 +21,19 @@ export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewPr } } + const handleOpenVscode = async () => { + if (envPath) { + await tauri.openInVscode(envPath, envName) + } + } + + const handleOpenTerminal = async () => { + if (envPath) { + const windowName = `ushadow-${envName}` + await tauri.openTmuxInTerminal(windowName, envPath) + } + } + return (
{/* Header bar */} @@ -49,6 +63,29 @@ export function EmbeddedView({ url, envName, envColor, onClose }: EmbeddedViewPr > + + {/* VSCode and Terminal buttons - only show if envPath exists */} + {envPath && ( + <> + + + + )} + -
-
-
-                {tmuxInfo}
-              
-
-
- {tmuxInfo.includes('No tmux server running') && ( - - )} -
- -
-
- - )} + {/* Tmux Manager Dialog */} + setShowTmuxManager(false)} + /> ) } @@ -354,8 +288,39 @@ interface EnvironmentCardProps { function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, onDelete, onAttachTmux, isLoading, tmuxStatus }: EnvironmentCardProps) { const [showTmuxWindows, setShowTmuxWindows] = useState(false) const [tmuxWindows, setTmuxWindows] = useState>([]) + const [showTmuxOutput, setShowTmuxOutput] = useState(false) + const [tmuxOutput, setTmuxOutput] = useState('') + const [claudeStatus, setClaudeStatus] = useState(null) + const [loadingClaudeStatus, setLoadingClaudeStatus] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const colors = getColors(environment.color || environment.name) + // Load Claude status when tmux is active + useEffect(() => { + if (tmuxStatus && tmuxStatus.exists && environment.is_worktree) { + loadClaudeStatus() + // Poll every 10 seconds + const interval = setInterval(loadClaudeStatus, 10000) + return () => clearInterval(interval) + } + }, [tmuxStatus?.exists, environment.name]) + + const loadClaudeStatus = async () => { + if (loadingClaudeStatus) return + setLoadingClaudeStatus(true) + try { + const windowName = `ushadow-${environment.name.toLowerCase()}` + console.log(`[${environment.name}] Checking Claude status for window: ${windowName}`) + const status = await tauri.getClaudeStatus(windowName) + console.log(`[${environment.name}] Claude status:`, status) + setClaudeStatus(status) + } catch (err) { + console.error('Failed to load Claude status:', err) + } finally { + setLoadingClaudeStatus(false) + } + } + const localhostUrl = environment.localhost_url || (environment.backend_port ? `http://localhost:${environment.webui_port || environment.backend_port}` : null) const handleOpenUrl = (url: string) => { @@ -405,12 +370,62 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, o } } + const handleOpenTmuxWindow = async (windowName: string) => { + try { + await tauri.openTmuxInTerminal(windowName) + } catch (err) { + console.error('Failed to open tmux window:', err) + } + } + + const handleToggleTmuxOutput = async () => { + if (!showTmuxOutput) { + // Load tmux output + try { + const windowName = `ushadow-${environment.name.toLowerCase()}` + const output = await tauri.captureTmuxPane(windowName) + setTmuxOutput(output) + } catch (err) { + console.error('Failed to capture tmux output:', err) + setTmuxOutput('Failed to capture tmux output') + } + } + setShowTmuxOutput(!showTmuxOutput) + } + + const handleRefreshTmuxOutput = async () => { + try { + const windowName = `ushadow-${environment.name.toLowerCase()}` + const output = await tauri.captureTmuxPane(windowName) + setTmuxOutput(output) + } catch (err) { + console.error('Failed to capture tmux output:', err) + } + } + + const handleDelete = () => { + if (onDelete) { + setIsDeleting(true) + // Wait for animation to complete before actually deleting + setTimeout(() => { + onDelete() + }, 300) // Match animation duration + } + } + return (
@@ -449,7 +464,24 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, o {getTmuxStatusIcon(tmuxStatus)} {tmuxStatus.current_command || 'tmux'} )} + {/* Claude Code status badge */} + {claudeStatus && claudeStatus.is_running && ( + + + Claude + + )}
+ {/* Claude current task */} + {claudeStatus && claudeStatus.is_running && ( +
+ 🤖 {claudeStatus.current_task || 'Active (click Claude badge to view output)'} +
+ )} {/* Container tags */} {environment.containers.length > 0 && (
@@ -497,31 +529,18 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, o {/* Top right buttons */}
- {/* Tmux windows toggle - for worktrees */} - {environment.is_worktree && tmuxStatus && tmuxStatus.exists && ( + {/* Terminal button - Opens iTerm2 or Terminal.app for all worktrees */} + {environment.is_worktree && environment.path && ( - )} - - {/* Tmux attach button - for worktrees without existing tmux */} - {environment.is_worktree && environment.path && onAttachTmux && (!tmuxStatus || !tmuxStatus.exists) && ( - )} @@ -572,14 +591,24 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, o {window.active && (active)}
- +
+ + +
))} @@ -587,9 +616,35 @@ function EnvironmentCard({ environment, onStart, onStop, onOpenInApp, onMerge, o )} - {/* Start/Stop and Merge buttons - bottom */} + {/* Tmux output drawer */} + {showTmuxOutput && ( +
+
+
Terminal Output:
+
+ + +
+
+
+ {tmuxOutput || 'Loading...'} +
+
+ )} + + {/* Action buttons - bottom */}
- {/* Merge and Delete buttons - left side, only for worktrees */} + {/* Left side: Merge, Stop, Delete buttons */}
{environment.is_worktree && onMerge && ( )} - {environment.is_worktree && onDelete && ( + {environment.running && ( + )} + {onDelete && ( + - ) : ( + {!environment.running && (
- {/* Environment Name */} + {/* Branch Name - Now first to enable auto-naming */}
- setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && isValid && !isChecking && handleSubmit()} - className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" - placeholder="e.g., dev, staging, feature-x" - autoFocus - data-testid="env-name-input" +

- Names will be converted to lowercase (e.g., "Orange" → "orange") + Select a Claude-created branch or any existing branch, or type a new name. If it doesn't exist, it will be created from main.

- {/* Branch Name */} + {/* Environment Name - Auto-filled from branch */}
setBranch(e.target.value)} + value={name} + onChange={(e) => handleNameChange(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && isValid && !isChecking && handleSubmit()} className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" - placeholder={name || 'feature/my-branch'} - data-testid="branch-input" + placeholder="e.g., dev, staging, feature-x" + data-testid="env-name-input" />

- Branch names will be converted to lowercase + Auto-filled from branch name. Edit if you prefer a different name.

diff --git a/ushadow/launcher/src/components/PrerequisitesPanel.tsx b/ushadow/launcher/src/components/PrerequisitesPanel.tsx index 423716dd..6d0a0947 100644 --- a/ushadow/launcher/src/components/PrerequisitesPanel.tsx +++ b/ushadow/launcher/src/components/PrerequisitesPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { CheckCircle, XCircle, AlertCircle, Loader2, ChevronDown, ChevronRight, Download } from 'lucide-react' +import { CheckCircle, XCircle, AlertCircle, Loader2, ChevronDown, ChevronRight, ChevronUp, Download } from 'lucide-react' import type { Prerequisites } from '../hooks/useTauri' interface PrerequisitesPanelProps { @@ -9,6 +9,8 @@ interface PrerequisitesPanelProps { installingItem: string | null onInstall: (item: 'git' | 'docker' | 'tailscale' | 'homebrew' | 'python') => void onStartDocker: () => void + showDevTools: boolean + onToggleDevTools: () => void } export function PrerequisitesPanel({ @@ -18,6 +20,8 @@ export function PrerequisitesPanel({ installingItem, onInstall, onStartDocker, + showDevTools, + onToggleDevTools, }: PrerequisitesPanelProps) { const getOverallStatus = () => { if (!prerequisites) return 'checking' @@ -33,17 +37,17 @@ export function PrerequisitesPanel({ return (
{/* Header */} -
+ - +
{/* Content */} {expanded && ( @@ -109,6 +113,18 @@ export function PrerequisitesPanel({ /> )} + + {/* Drawer toggle - centered at bottom */} +
+ +
) } diff --git a/ushadow/launcher/src/components/SettingsDialog.tsx b/ushadow/launcher/src/components/SettingsDialog.tsx new file mode 100644 index 00000000..b9b40cbc --- /dev/null +++ b/ushadow/launcher/src/components/SettingsDialog.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect } from 'react' +import { X, Settings, Eye, EyeOff, Save } from 'lucide-react' +import { tauri, type LauncherSettings } from '../hooks/useTauri' + +interface SettingsDialogProps { + isOpen: boolean + onClose: () => void +} + +export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { + const [settings, setSettings] = useState({ + default_admin_email: null, + default_admin_password: null, + default_admin_name: null, + }) + const [showPassword, setShowPassword] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [saveSuccess, setSaveSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + // Load settings when dialog opens + useEffect(() => { + if (isOpen) { + setIsLoading(true) + setSaveSuccess(false) + tauri.loadLauncherSettings() + .then(loaded => { + setSettings(loaded) + }) + .catch(err => { + console.error('Failed to load settings:', err) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [isOpen]) + + if (!isOpen) return null + + const handleSave = async () => { + setIsSaving(true) + setSaveSuccess(false) + + try { + await tauri.saveLauncherSettings(settings) + setSaveSuccess(true) + setTimeout(() => { + onClose() + }, 1000) + } catch (error) { + console.error('Failed to save settings:', error) + alert(`Failed to save settings: ${error}`) + } finally { + setIsSaving(false) + } + } + + const isValid = settings.default_admin_email && settings.default_admin_password + + return ( +
+
+ {/* Header */} +
+

+ + Launcher Settings +

+ +
+ + {isLoading ? ( +
+ Loading settings... +
+ ) : ( + <> + {/* Description */} +

+ Configure default admin credentials that will be used for all new environments. + These credentials are stored locally and used to auto-create users. +

+ + {/* Admin Name */} +
+ + setSettings({ ...settings, default_admin_name: e.target.value || null })} + className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" + placeholder="Administrator" + data-testid="settings-admin-name" + /> +
+ + {/* Admin Email */} +
+ + setSettings({ ...settings, default_admin_email: e.target.value || null })} + className="w-full bg-surface-700 rounded-lg px-3 py-2 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" + placeholder="admin@example.com" + autoFocus + data-testid="settings-admin-email" + /> +
+ + {/* Admin Password */} +
+ +
+ setSettings({ ...settings, default_admin_password: e.target.value || null })} + className="w-full bg-surface-700 rounded-lg px-3 py-2 pr-10 outline-none text-sm focus:ring-2 focus:ring-primary-500/50" + placeholder="••••••••" + data-testid="settings-admin-password" + /> + +
+
+ + {/* Success Message */} + {saveSuccess && ( +
+ Settings saved successfully! +
+ )} + + {/* Actions */} +
+ + +
+ + {/* Helper text */} +

+ 💡 These credentials will be used to automatically create an admin user when you create a new environment. +

+ + )} +
+
+ ) +} diff --git a/ushadow/launcher/src/hooks/useTauri.ts b/ushadow/launcher/src/hooks/useTauri.ts index bd79a194..be8b29bd 100644 --- a/ushadow/launcher/src/hooks/useTauri.ts +++ b/ushadow/launcher/src/hooks/useTauri.ts @@ -60,6 +60,13 @@ export interface Discovery { tailscale_ok: boolean } +// Launcher settings +export interface LauncherSettings { + default_admin_email: string | null + default_admin_password: string | null + default_admin_name: string | null +} + // Tauri command wrappers with proper typing export const tauri = { // System checks @@ -116,6 +123,7 @@ export const tauri = { // Worktree management listWorktrees: (mainRepo: string) => invoke('list_worktrees', { mainRepo }), + listGitBranches: (mainRepo: string) => invoke('list_git_branches', { mainRepo }), checkWorktreeExists: (mainRepo: string, branch: string) => invoke('check_worktree_exists', { mainRepo, branch }), createWorktree: (mainRepo: string, worktreesDir: string, name: string, baseBranch?: string) => invoke('create_worktree', { mainRepo, worktreesDir, name, baseBranch }), @@ -138,6 +146,15 @@ export const tauri = { getTmuxSessions: () => invoke('get_tmux_sessions'), killTmuxWindow: (windowName: string) => invoke('kill_tmux_window', { windowName }), killTmuxServer: () => invoke('kill_tmux_server'), + openTmuxInTerminal: (windowName: string, worktreePath: string) => invoke('open_tmux_in_terminal', { windowName, worktreePath }), + captureTmuxPane: (windowName: string) => invoke('capture_tmux_pane', { windowName }), + getClaudeStatus: (windowName: string) => invoke('get_claude_status', { windowName }), + + // Settings + loadLauncherSettings: () => invoke('load_launcher_settings'), + saveLauncherSettings: (settings: LauncherSettings) => invoke('save_launcher_settings', { settings }), + writeCredentialsToWorktree: (worktreePath: string, adminEmail: string, adminPassword: string, adminName?: string) => + invoke('write_credentials_to_worktree', { worktreePath, adminEmail, adminPassword, adminName }), } // WorktreeInfo type @@ -171,4 +188,11 @@ export interface TmuxSessionInfo { windows: TmuxWindowInfo[] } +// Claude Code status types +export interface ClaudeStatus { + is_running: boolean + current_task: string | null + last_output: string | null +} + export default tauri diff --git a/ushadow/launcher/src/index.css b/ushadow/launcher/src/index.css index c625543d..78c09eba 100644 --- a/ushadow/launcher/src/index.css +++ b/ushadow/launcher/src/index.css @@ -2,6 +2,21 @@ @tailwind components; @tailwind utilities; +/* Enable text selection by default everywhere */ +* { + user-select: text; + -webkit-user-select: text; +} + +/* Disable selection only for UI controls */ +button, +input[type="button"], +input[type="submit"], +input[type="reset"] { + user-select: none; + -webkit-user-select: none; +} + /* Custom scrollbar for dark theme */ ::-webkit-scrollbar { width: 8px; From 0ace0a4a5364a00bca408fa5df450c99577b3f38 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Sat, 17 Jan 2026 23:32:55 +0000 Subject: [PATCH 04/70] added generic installer --- ushadow/launcher/GENERIC_INSTALLER.md | 421 +++++++++++++++ ushadow/launcher/README.md | 61 ++- ushadow/launcher/ROADMAP.md | 502 ++++++++++++++++++ ushadow/launcher/TMUX_INTEGRATION.md | 413 ++++++++++++++ ushadow/launcher/public/iterm-icon.png | Bin 0 -> 37505 bytes ushadow/launcher/src-tauri/Cargo.lock | 20 + ushadow/launcher/src-tauri/Cargo.toml | 1 + ushadow/launcher/src-tauri/prerequisites.yaml | 192 +++++++ .../src/commands/generic_installer.rs | 383 +++++++++++++ .../launcher/src-tauri/src/commands/mod.rs | 4 + .../src-tauri/src/commands/prerequisites.rs | 6 + .../src/commands/prerequisites_config.rs | 136 +++++ ushadow/launcher/src-tauri/src/main.rs | 10 + 13 files changed, 2146 insertions(+), 3 deletions(-) create mode 100644 ushadow/launcher/GENERIC_INSTALLER.md create mode 100644 ushadow/launcher/ROADMAP.md create mode 100644 ushadow/launcher/TMUX_INTEGRATION.md create mode 100644 ushadow/launcher/public/iterm-icon.png create mode 100644 ushadow/launcher/src-tauri/prerequisites.yaml create mode 100644 ushadow/launcher/src-tauri/src/commands/generic_installer.rs create mode 100644 ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs diff --git a/ushadow/launcher/GENERIC_INSTALLER.md b/ushadow/launcher/GENERIC_INSTALLER.md new file mode 100644 index 00000000..bce3f35f --- /dev/null +++ b/ushadow/launcher/GENERIC_INSTALLER.md @@ -0,0 +1,421 @@ +# Generic Prerequisite Installer + +The generic installer system makes it easy to add new prerequisites without writing custom installation code. All prerequisite installations are now driven by the `prerequisites.yaml` configuration file. + +## Quick Start + +### Adding a New Prerequisite + +1. Add the prerequisite to `prerequisites.yaml`: + +```yaml +prerequisites: + - id: nodejs + name: Node.js + display_name: Node.js + description: JavaScript runtime + platforms: [macos, windows, linux] + check_command: node --version + optional: false + category: development +``` + +2. Add installation methods for each platform: + +```yaml +installation_methods: + nodejs: + macos: + method: homebrew + package: node + windows: + method: winget + package: OpenJS.NodeJS + linux: + method: package_manager + packages: + apt: nodejs + yum: nodejs + dnf: nodejs +``` + +3. Use the generic installer from frontend: + +```typescript +import { invoke } from '@tauri-apps/api' + +// Install Node.js +await invoke('install_prerequisite', { prerequisiteId: 'nodejs' }) + +// Start a service (for services like Docker) +await invoke('start_prerequisite', { prerequisiteId: 'docker' }) +``` + +That's it! No Rust code changes needed. + +## Installation Methods + +The generic installer supports 6 installation strategies: + +### 1. Homebrew (macOS) + +Installs packages via Homebrew. Automatically detects if package is a cask or formula. + +```yaml +method: homebrew +package: docker # Package name +``` + +**Examples:** +- `docker` → Installs as cask with admin privileges +- `git` → Installs as formula +- `python@3.12` → Installs specific version + +### 2. Winget (Windows) + +Installs via Windows Package Manager. + +```yaml +method: winget +package: Docker.DockerDesktop # Package ID +``` + +**Examples:** +- `Docker.DockerDesktop` +- `Git.Git` +- `OpenJS.NodeJS` + +### 3. Download + +Downloads installer and opens it for manual installation. + +```yaml +method: download +url: https://example.com/installer.exe +``` + +**Special Cases:** +- Homebrew `.pkg` files are downloaded and opened automatically +- Other files open the URL in the default browser + +### 4. Script + +Downloads and executes an installation script. + +```yaml +method: script +url: https://get.docker.com +``` + +**Process:** +1. Downloads script from URL +2. Saves to temp directory +3. Makes executable (Unix only) +4. Executes with bash + +**Security Note:** Only use scripts from trusted sources. + +### 5. Package Manager (Linux) + +Installs via system package manager (apt, yum, or dnf). + +```yaml +method: package_manager +packages: + apt: docker.io + yum: docker + dnf: docker +``` + +The installer automatically detects which package manager is available and uses the appropriate package name. + +### 6. Cargo (Rust) + +Installs Rust packages via cargo. + +```yaml +method: cargo +package: workmux +``` + +## API Reference + +### `install_prerequisite(prerequisite_id: String) -> Result` + +Generic installer that reads configuration and executes the appropriate installation method. + +```typescript +// TypeScript +const result = await invoke('install_prerequisite', { + prerequisiteId: 'docker' +}) +console.log(result) // "docker installed successfully via Homebrew" +``` + +```rust +// Rust +let result = install_prerequisite("docker".to_string()).await?; +``` + +**Process:** +1. Loads `prerequisites.yaml` +2. Finds installation method for current platform +3. Executes appropriate installation strategy +4. Returns success message or error + +### `start_prerequisite(prerequisite_id: String) -> Result` + +Starts a service prerequisite (only for prerequisites with `has_service: true`). + +```typescript +// TypeScript +const result = await invoke('start_prerequisite', { + prerequisiteId: 'docker' +}) +console.log(result) // "Docker Desktop starting..." +``` + +**Supported Services:** +- `docker` - macOS/Windows/Linux + +## Complete Example + +Here's a complete example of adding PostgreSQL as a prerequisite: + +### 1. Add to prerequisites.yaml + +```yaml +prerequisites: + - id: postgresql + name: PostgreSQL + display_name: PostgreSQL + description: Relational database + platforms: [macos, windows, linux] + check_command: psql --version + optional: true + has_service: true + category: infrastructure + +installation_methods: + postgresql: + macos: + method: homebrew + package: postgresql@15 + windows: + method: download + url: https://www.postgresql.org/download/windows/ + linux: + method: package_manager + packages: + apt: postgresql + yum: postgresql + dnf: postgresql +``` + +### 2. Use in Frontend + +```typescript +import { invoke } from '@tauri-apps/api' + +// Install PostgreSQL +try { + const result = await invoke('install_prerequisite', { + prerequisiteId: 'postgresql' + }) + console.log(result) +} catch (error) { + console.error('Installation failed:', error) +} + +// Start PostgreSQL (if it's a service) +try { + const result = await invoke('start_prerequisite', { + prerequisiteId: 'postgresql' + }) + console.log(result) +} catch (error) { + console.error('Start failed:', error) +} +``` + +## Migration from Old System + +### Before (Custom Installer) + +```rust +// src/commands/installer.rs +#[cfg(target_os = "macos")] +#[tauri::command] +pub async fn install_nodejs_macos() -> Result { + if !check_brew_installed() { + return Err("Homebrew is not installed".to_string()); + } + let output = brew_command() + .args(["install", "node"]) + .output() + .map_err(|e| format!("Failed to run brew: {}", e))?; + if output.status.success() { + Ok("Node.js installed successfully via Homebrew".to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Brew install failed: {}", stderr)) + } +} + +#[cfg(target_os = "windows")] +#[tauri::command] +pub async fn install_nodejs_windows() -> Result { + // Windows implementation... +} + +// main.rs - Add to invoke_handler +install_nodejs_macos, +install_nodejs_windows, +``` + +### After (Generic Installer) + +```yaml +# prerequisites.yaml +installation_methods: + nodejs: + macos: + method: homebrew + package: node + windows: + method: winget + package: OpenJS.NodeJS +``` + +```typescript +// Frontend +await invoke('install_prerequisite', { prerequisiteId: 'nodejs' }) +``` + +**Benefits:** +- ✅ No Rust code changes +- ✅ No main.rs updates +- ✅ Easy to maintain +- ✅ Consistent across platforms +- ✅ YAML validation + +## Advanced Usage + +### Custom Installation Logic + +For prerequisites requiring custom installation logic, you can still add custom methods in `generic_installer.rs`: + +```rust +async fn execute_installation( + prereq_id: &str, + method: &InstallationMethod, + platform: &str, +) -> Result { + match method.method.as_str() { + "homebrew" => install_via_homebrew(prereq_id, method).await, + "custom_nodejs" => install_nodejs_custom(prereq_id, method).await, // Custom + _ => Err(format!("Unknown installation method: {}", method.method)) + } +} +``` + +Then in YAML: + +```yaml +nodejs: + macos: + method: custom_nodejs +``` + +### Platform Detection + +The installer automatically detects the platform: +- macOS → `"macos"` +- Windows → `"windows"` +- Linux → `"linux"` + +You can have different installation methods per platform in the YAML. + +### Error Handling + +All installation methods return `Result`: +- `Ok(message)` - Installation succeeded with a success message +- `Err(message)` - Installation failed with an error message + +## Testing + +### Test Prerequisites Installation + +```bash +# Run the launcher in dev mode +cargo tauri dev + +# In the app, try installing a prerequisite +# Check the console for debug output +``` + +### Debug Mode + +Enable debug output to see what's happening: + +```rust +eprintln!("Installing {} via Homebrew: {}", prereq_id, package); +``` + +## Best Practices + +1. **Use Generic Installer** - Prefer adding to YAML over writing custom code +2. **Test on Each Platform** - Verify installation works on macOS/Windows/Linux +3. **Handle Errors Gracefully** - Provide helpful error messages +4. **Document Prerequisites** - Add clear descriptions in YAML +5. **Keep YAML Simple** - Avoid complex logic in configuration +6. **Version Pin When Needed** - Use specific versions (e.g., `python@3.12`) + +## Troubleshooting + +### Installation Fails Silently + +Check if the method is registered in `execute_installation()`: + +```rust +match method.method.as_str() { + "your_method" => { /* handler */ } + _ => Err(format!("Unknown installation method: {}", method.method)) +} +``` + +### Package Not Found + +- **Homebrew**: Check package name with `brew search ` +- **Winget**: Check package ID with `winget search ` +- **Linux**: Verify package name with `apt search ` etc. + +### Permission Denied + +Some installations require admin privileges: +- macOS: osascript with administrator privileges +- Windows: UAC prompt +- Linux: sudo + +The generic installer handles this automatically for supported methods. + +## Future Enhancements + +Potential improvements to the generic installer: + +1. **Post-install hooks** - Run commands after installation +2. **Dependency checking** - Ensure prerequisites are installed in order +3. **Version validation** - Check installed version meets requirements +4. **Rollback support** - Uninstall on failure +5. **Progress tracking** - Report installation progress +6. **Batch installation** - Install multiple prerequisites at once + +## Summary + +The generic installer makes it trivial to add new prerequisites: + +1. Add prerequisite definition to YAML +2. Add installation methods to YAML +3. Call `install_prerequisite(id)` from frontend + +No Rust code changes needed! ✨ diff --git a/ushadow/launcher/README.md b/ushadow/launcher/README.md index cf4d576b..407c618d 100644 --- a/ushadow/launcher/README.md +++ b/ushadow/launcher/README.md @@ -1,14 +1,69 @@ # Ushadow Desktop Launcher -A Tauri-based desktop application that manages Ushadow's Docker containers and provides a native app experience. +A Tauri-based desktop application for orchestrating parallel development environments with git worktrees, tmux sessions, and Docker containers. ## Features -- **Prerequisite Checking**: Verifies Docker and Tailscale are installed -- **Container Management**: Start/stop Docker containers with one click +### Core Functionality +- **Git Worktree Management**: Create, manage, and delete git worktrees for parallel development +- **Tmux Integration**: Persistent terminal sessions with automatic window management +- **Container Orchestration**: Start/stop Docker containers per environment +- **Environment Discovery**: Auto-detect and manage multiple environments +- **Fast Status Checks**: Cached Tailscale/Docker polling for instant feedback + +### Developer Experience +- **One-Click Terminal Access**: Open Terminal.app directly into environment's tmux session +- **VS Code Integration**: Launch VS Code with environment-specific colors +- **Real-time Status Badges**: Visual indicators for tmux activity (Working/Waiting/Done/Error) +- **Quick Environment Switching**: Manage multiple parallel tasks/features simultaneously +- **Merge & Cleanup**: Rebase and merge worktrees back to main with one click + +### Infrastructure +- **Prerequisite Checking**: Verifies Docker, Tailscale, Git, and Tmux - **System Tray**: Runs in background with quick access menu - **Cross-Platform**: Builds for macOS (DMG), Windows (EXE), and Linux (DEB/AppImage) +## Quick Start + +```bash +# Install dependencies +npm install + +# Start development mode +npm run tauri:dev + +# The launcher will: +# 1. Auto-detect existing environments/worktrees +# 2. Start tmux server if worktrees exist +# 3. Show all environments with real-time status +``` + +### First-Time Usage + +1. **Set Project Root**: Click the folder icon to point to your Ushadow repo +2. **Check Prerequisites**: Verify Docker, Tailscale, Git, Tmux are installed +3. **Start Infrastructure**: Start required containers (postgres, redis, etc.) +4. **Create Environment**: Click "New Environment" and choose: + - **Clone** - Create new git clone (traditional) + - **Worktree** - Create git worktree (recommended for parallel dev) + +### Using Tmux Sessions + +- **Purple Terminal Icon** on environment cards - Click to open Terminal and attach to tmux +- **Global "Tmux" Button** in header - View all sessions/windows +- **Status Badges** next to branch names - See what's running in each tmux window + +**Note**: Terminal opening currently works on **macOS only** (via Terminal.app). Linux/Windows support is planned. See [CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md) for details. + +## Documentation + +- **[TMUX_INTEGRATION.md](./TMUX_INTEGRATION.md)** - Complete guide to tmux integration features (Phase 1) +- **[ROADMAP.md](./ROADMAP.md)** - Full vision including Vibe Kanban and remote management (Phases 2-4) +- **[CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md)** - Cross-platform terminal opening strategy +- **[TESTING.md](./TESTING.md)** - Testing guidelines +- **[RELEASING.md](./RELEASING.md)** - Release process +- **[CHANGELOG.md](./CHANGELOG.md)** - Version history + ## Prerequisites ### Development diff --git a/ushadow/launcher/ROADMAP.md b/ushadow/launcher/ROADMAP.md new file mode 100644 index 00000000..c9a7a0f8 --- /dev/null +++ b/ushadow/launcher/ROADMAP.md @@ -0,0 +1,502 @@ +# Ushadow Launcher: Development Roadmap + +## Vision + +The Ushadow Launcher aims to be a comprehensive development environment orchestration tool that bridges project management (Vibe Kanban), local development (worktrees + tmux), and remote development (Tailscale-connected instances). It enables developers to seamlessly work on multiple tasks in parallel, switch between local and remote environments, and maintain persistent development sessions. + +## Current State (Phase 1: Local Tmux Integration) ✅ + +**Platform Support**: macOS only for terminal opening. Linux/Windows have placeholder code that won't work reliably. See [CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md) for cross-platform strategy. + +### What We've Built + +**Fast Environment Detection** +- 10-second tailscale status caching +- Reduced environment ready time from 12+ seconds to ~2 seconds +- Smart polling that doesn't spam slow external checks + +**Persistent Tmux Sessions** +- Auto-start tmux server on launcher startup +- Single `workmux` session for all worktrees +- Per-environment tmux windows: `ushadow-{env-name}` +- Terminal.app integration (macOS) - click button to open and attach + +**Visual Feedback** +- Global "Tmux" button showing all sessions/windows +- Per-environment tmux button (purple terminal icon) +- Real-time activity badges (🤖 Working, 💬 Waiting, ✅ Done, ❌ Error) +- Activity log with success/error messages + +**Worktree Management** +- Create worktrees via launcher UI +- Merge & Cleanup with rebase +- Delete environments (containers + worktree + tmux) +- VS Code integration with environment colors + +### Architecture Foundation + +``` +┌─────────────────────────────────────────────┐ +│ Ushadow Launcher (Tauri) │ +│ ┌──────────────┐ ┌─────────────────┐ │ +│ │ Frontend │ ◄──► │ Rust Backend │ │ +│ │ (React/TS) │ │ (Commands) │ │ +│ └──────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────┐ ┌─────────┐ ┌─────────┐ + │ Docker │ │ Tmux │ │ Git │ + │Compose │ │ Server │ │Worktree │ + └────────┘ └─────────┘ └─────────┘ +``` + +## Phase 2: Vibe Kanban Integration (In Progress) + +### Vision + +Vibe Kanban is a task management system that automatically provisions development environments for each task. When you pick up a task, you get a fresh worktree, tmux session, and containerized environment - all preconfigured and ready to code. + +### Observed Pattern + +Current Vibe Kanban worktrees follow this structure: +``` +/tmp/vibe-kanban/worktrees/ +├── 5349-install-and-inte/ +│ └── Ushadow/ (branch: vk/5349-install-and-inte) +├── 8e65-install-and-inte/ +│ └── Ushadow/ (branch: vk/8e65-install-and-inte) +└── a56a-create-an-overvi/ + └── Ushadow/ (branch: vk/a56a-create-an-overvi) +``` + +### Planned Integration + +**Task-Driven Worktree Creation** +- Detect Vibe Kanban tasks from API or webhook +- Auto-create worktree: `{task-id}-{task-description}` +- Auto-create branch: `vk/{task-id}-{task-description}` +- Auto-start containers with task-specific config +- Auto-create tmux window in workmux session + +**Kanban Board View in Launcher** +- Show tasks from Vibe Kanban API +- Display task status: Todo, In Progress, Review, Done +- Click task to create/switch to its environment +- Visual indication of which tasks have active environments +- Drag-and-drop to change task status (updates Kanban + git) + +**Task Context Awareness** +- Store task metadata in `.env.{task-id}` file +- Display task description in environment card +- Link to Kanban board from launcher +- Show task assignee, labels, due date +- Integration with PR creation (auto-link task ID) + +**Lifecycle Management** +- Auto-archive worktrees when task marked as Done +- Prompt to merge when moving to Review column +- Cleanup stale task environments (configurable timeout) +- Preserve tmux logs for completed tasks + +### Implementation Checklist + +**Backend (Rust)** +- [ ] Add Vibe Kanban API client +- [ ] Implement task polling/webhook receiver +- [ ] Create `create_task_environment(task_id, description)` command +- [ ] Add task metadata storage/retrieval +- [ ] Implement task status sync +- [ ] Add cleanup job for stale tasks + +**Frontend (TypeScript)** +- [ ] Create Kanban board component +- [ ] Add task cards with environment status +- [ ] Implement drag-and-drop status changes +- [ ] Add "Create from Task" dialog +- [ ] Show task metadata on environment cards +- [ ] Add task filtering/search + +**Integration** +- [ ] Define Vibe Kanban API contract +- [ ] Set up authentication/authorization +- [ ] Implement webhook receiver for task updates +- [ ] Add configuration UI for Kanban connection +- [ ] Create task template system + +### Configuration + +```yaml +# ~/.ushadow/vibe-kanban.yml +vibe_kanban: + enabled: true + api_url: "https://kanban.example.com/api" + api_token: "${VIBE_KANBAN_TOKEN}" + worktree_dir: "/tmp/vibe-kanban/worktrees" + auto_create_environments: true + auto_cleanup_days: 7 + branch_prefix: "vk" + task_id_format: "{id}-{slug}" +``` + +## Phase 3: Remote Development Management (Planned) + +### Vision + +Enable seamless development on remote machines (cloud VMs, development servers) with the same UX as local development. The launcher becomes a unified control panel for both local and remote environments. + +### Use Cases + +**Remote Development Server** +- Company provides beefy dev servers (GPU, RAM, CPU) +- Developers connect via Tailscale +- Launcher manages remote worktrees, tmux, containers +- Terminal sessions tunnel through to remote tmux +- VS Code Remote-SSH integration + +**Cloud Staging Environments** +- Each task gets a cloud instance for testing +- Auto-provision on task start +- Auto-destroy on task completion +- Share preview URL with team via Tailscale +- Cost tracking per task/developer + +**Multi-Region Development** +- Work on low-latency servers near customers +- Test region-specific features +- Replicate production topology +- Debug region-specific issues + +### Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Ushadow Launcher (Local) │ +│ Shows both local and remote environments │ +└──────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌──────────────────────┐ +│ Local Machine │ │ Remote Server(s) │ +│ │ │ (via Tailscale) │ +│ • Tmux │ │ • Tmux │ +│ • Docker │ │ • Docker │ +│ • Worktrees │ │ • Worktrees │ +└─────────────────┘ │ • Ushadow Agent │ + └──────────────────────┘ +``` + +### Components + +**Ushadow Agent (Remote)** +- Lightweight daemon running on remote servers +- Exposes Tauri-like command API over HTTP/gRPC +- Manages worktrees, tmux, containers remotely +- Reports status back to launcher +- Handles authentication via Tailscale identity + +**Launcher Remote Manager** +- Discover remote agents via Tailscale +- Display remote environments alongside local ones +- Tunnel terminal connections (SSH + tmux) +- Sync git credentials securely +- Monitor remote resource usage + +**Terminal Tunneling** +- Click remote env's tmux button → SSH tunnel opens +- Local Terminal.app connects to remote tmux +- Seamless experience (user doesn't see SSH) +- Clipboard sync over SSH +- Port forwarding for web UIs + +### Implementation Checklist + +**Phase 3.1: Remote Agent** +- [ ] Design Ushadow Agent API (gRPC/HTTP) +- [ ] Implement agent in Rust (reuse launcher commands) +- [ ] Add Tailscale identity authentication +- [ ] Package agent as systemd service +- [ ] Create agent installation script +- [ ] Build agent configuration UI + +**Phase 3.2: Remote Discovery** +- [ ] Implement Tailscale device discovery +- [ ] Detect Ushadow Agents on Tailscale network +- [ ] Show remote servers in launcher UI +- [ ] Display remote environment status +- [ ] Health checking for remote agents + +**Phase 3.3: Remote Control** +- [ ] Implement SSH tunnel management +- [ ] Remote tmux attach via SSH +- [ ] Remote command execution via agent +- [ ] VS Code Remote-SSH integration +- [ ] Port forwarding for web UIs + +**Phase 3.4: Provisioning** +- [ ] Terraform/cloud provider integration +- [ ] Auto-provision VMs for tasks +- [ ] Auto-destroy on task completion +- [ ] Cost estimation/tracking +- [ ] Multi-cloud support (AWS, GCP, Azure) + +### Security Considerations + +**Authentication** +- Tailscale identity as primary auth +- Agent API keys for additional security +- SSH key management (agent forwarding) +- No passwords, all key-based + +**Authorization** +- Per-agent ACLs (who can control what) +- Workspace isolation (multi-tenant support) +- Audit logging for remote commands +- Rate limiting on agent API + +**Data Protection** +- Git credential forwarding over SSH +- Encrypted environment variables +- Secrets management integration (Vault, 1Password) +- No secrets stored in launcher config + +## Phase 4: Advanced Features (Future) + +### Team Collaboration + +**Shared Environments** +- Multiple developers in same tmux session (tmate/teleconsole) +- Real-time code pairing +- Environment sharing via Tailscale URL +- Session recording/replay + +**Environment Templates** +- Pre-configured stacks (Next.js, Django, Go microservices) +- One-click environment setup from template +- Template marketplace/sharing +- Version-controlled templates + +### CI/CD Integration + +**PR Previews** +- Auto-create environment for each PR +- Run tests in isolated environment +- Deploy preview to Tailscale URL +- Auto-cleanup on PR merge/close + +**Pipeline Debugging** +- Reproduce CI environment locally +- Attach to failed pipeline containers +- Interactive debugging of CI failures +- Log aggregation across environments + +### Observability + +**Metrics & Monitoring** +- CPU/RAM/Disk usage per environment +- Container health metrics +- Tmux session activity tracking +- Cost attribution per task/developer + +**Distributed Tracing** +- Trace requests across environments +- Service mesh visualization +- Performance profiling +- Error tracking integration (Sentry) + +### AI/LLM Integration + +**Intelligent Environment Management** +- AI suggests which environments to cleanup +- Auto-detects stuck/idle containers +- Recommends resource allocation +- Task estimation based on environment usage + +**Code Context Awareness** +- Claude/GPT integration with environment context +- "Fix this error in my tmux session" +- "Deploy this branch to remote staging" +- Natural language environment control + +## Integration Points + +### External Tools + +| Tool | Integration | Status | +|------|-------------|--------| +| Git | Worktree creation, branch management | ✅ Complete | +| Tmux | Session management, terminal access | ✅ Complete | +| Docker | Container orchestration | ✅ Complete | +| VS Code | Editor integration, color coding | ✅ Complete | +| Tailscale | Networking, remote access | ✅ Partial | +| Vibe Kanban | Task management | 🚧 Planned | +| GitHub/GitLab | PR creation, CI status | 🚧 Planned | +| Slack/Discord | Notifications | 🚧 Planned | +| Terraform | Cloud provisioning | 🚧 Planned | +| Kubernetes | Container orchestration | 🚧 Planned | + +### API Design + +**Launcher → Agent Communication** +```rust +// Unified command interface for local and remote +trait EnvironmentManager { + async fn create_worktree(name: String, base_branch: String) -> Result; + async fn start_containers(env_name: String) -> Result<()>; + async fn attach_tmux(env_name: String) -> Result<()>; + async fn get_status() -> Result; +} + +// Local implementation (current) +struct LocalManager { /* ... */ } + +// Remote implementation (future) +struct RemoteManager { + agent_url: String, + ssh_tunnel: SshTunnel, +} +``` + +**Vibe Kanban Integration** +```typescript +interface VibeKanbanTask { + id: string + title: string + description: string + status: 'todo' | 'in_progress' | 'review' | 'done' + assignee: string + labels: string[] + due_date: string | null + environment?: { + created: boolean + running: boolean + worktree_path: string + tmux_window: string + } +} + +interface VibeKanbanAPI { + getTasks(): Promise + updateTaskStatus(taskId: string, status: string): Promise + createEnvironment(taskId: string): Promise + destroyEnvironment(taskId: string): Promise +} +``` + +## Migration Path + +### From Current State → Vibe Kanban Integration + +1. **Manual Testing** (Current) + - User manually creates worktrees in `/tmp/vibe-kanban/worktrees/` + - Tests naming conventions and workflows + - Validates integration points + +2. **API Stub** (Next) + - Create mock Vibe Kanban API + - Implement basic task CRUD + - Test launcher integration + +3. **Backend Integration** (Then) + - Connect to real Vibe Kanban API + - Implement webhook receiver + - Add task lifecycle management + +4. **UI Polish** (Finally) + - Build Kanban board view + - Add drag-and-drop + - Polish UX based on feedback + +### From Vibe Kanban → Remote Management + +1. **Agent Development** + - Extract launcher commands into shared library + - Build standalone agent + - Test local agent on same machine + +2. **Remote Discovery** + - Integrate Tailscale device API + - Detect agents on network + - Display in launcher UI + +3. **Remote Control** + - Implement SSH tunneling + - Test remote tmux attach + - Validate remote command execution + +4. **Provisioning** + - Start with manual VM setup + - Add Terraform templates + - Automate end-to-end + +## Success Metrics + +### Phase 2 (Vibe Kanban) +- [ ] 90% of tasks have auto-created environments +- [ ] <10 seconds from task assignment to ready environment +- [ ] 0 manual worktree creation commands +- [ ] <1 minute to switch between task environments + +### Phase 3 (Remote Management) +- [ ] Remote environments feel as fast as local +- [ ] <5 second latency for terminal access +- [ ] 100% of remote commands succeed (reliability) +- [ ] <2 minutes to provision new cloud instance + +### Overall +- [ ] Developers work on 3+ parallel tasks seamlessly +- [ ] 50% reduction in environment setup time +- [ ] 80% reduction in "works on my machine" issues +- [ ] Net Promoter Score >50 + +## Questions to Answer + +### Vibe Kanban +- [ ] What is the Vibe Kanban API endpoint/protocol? +- [ ] How do we authenticate (API token, OAuth, Tailscale identity)? +- [ ] What triggers environment creation (task status change, webhook)? +- [ ] How do we handle task reassignment (transfer environment ownership)? +- [ ] What happens to environments when tasks are archived? + +### Remote Management +- [ ] Which cloud providers to support first (AWS, GCP, Azure)? +- [ ] What VM specs to use (CPU, RAM, disk)? +- [ ] How to handle cost allocation (per user, per task, per team)? +- [ ] Should we support on-prem servers (not just cloud)? +- [ ] How to handle agent updates (auto-update, manual)? + +### General +- [ ] Multi-tenancy: support multiple organizations/teams? +- [ ] Pricing model: free tier, per-user, per-environment? +- [ ] Windows/Linux support priority (currently macOS-focused)? +- [ ] Open source vs proprietary (current code, agent, Kanban)? + +## Next Steps + +### Immediate (This Week) +1. Document Vibe Kanban integration requirements +2. Design task → environment mapping +3. Create mock Kanban API for testing +4. Build basic Kanban board UI component + +### Short-term (This Month) +1. Implement Vibe Kanban API client +2. Add webhook receiver for task updates +3. Build task-driven worktree creation +4. Test end-to-end workflow with real tasks + +### Medium-term (This Quarter) +1. Design Ushadow Agent API +2. Build agent prototype +3. Test agent on remote server via Tailscale +4. Implement SSH tunneling for remote tmux + +### Long-term (This Year) +1. Production-ready agent deployment +2. Cloud provisioning automation +3. Multi-cloud support +4. Team collaboration features + +--- + +**This roadmap is a living document. Update it as we build, learn, and pivot.** diff --git a/ushadow/launcher/TMUX_INTEGRATION.md b/ushadow/launcher/TMUX_INTEGRATION.md new file mode 100644 index 00000000..a59a6e6c --- /dev/null +++ b/ushadow/launcher/TMUX_INTEGRATION.md @@ -0,0 +1,413 @@ +# Tmux Integration for Ushadow Launcher + +## Overview + +The Ushadow Launcher integrates with tmux to provide persistent terminal sessions for git worktree environments. Each worktree can have its own dedicated tmux window, enabling developers to maintain multiple parallel development sessions with ease. + +**Note**: This document covers Phase 1 (Local Tmux Integration) of the Ushadow Launcher project. For the broader vision including Vibe Kanban integration and remote development management, see [ROADMAP.md](./ROADMAP.md). + +## Problems Solved + +### 1. Slow Environment Ready Detection +**Problem**: The launcher was polling tailscale status 7 times during environment startup, causing 12+ second delays even when containers were already running. + +**Solution**: Implemented 10-second caching for tailscale status checks in `discovery.rs`: +- First check is real, subsequent checks within 10 seconds use cached value +- Reduces startup time from ~12 seconds to ~2 seconds +- Cache stored in static `Mutex>` + +**Files**: `src-tauri/src/commands/discovery.rs` + +### 2. No Visual Indication of Tmux Sessions +**Problem**: Users couldn't see if tmux windows were created or what their status was. + +**Solution**: Added three layers of tmux visibility: +1. **Activity Log Feedback**: Shows `✓ Tmux window 'ushadow-{name}' created` messages +2. **Status Badges**: Real-time activity indicators on environment cards (🤖/💬/✅/❌) +3. **Tmux Info Dialog**: Global "Tmux" button in header shows all sessions/windows + +**Files**: `src/App.tsx`, `src/components/EnvironmentsPanel.tsx` + +### 3. Manual Tmux Requirement +**Problem**: Users had to manually start tmux before creating worktrees. + +**Solution**: Auto-start tmux in multiple places: +- When creating worktrees with workmux +- On launcher startup if worktrees exist +- Manual "Start Tmux Server" button in dialog +- Graceful fallback to regular git worktrees if tmux fails + +**Files**: `src-tauri/src/commands/worktree.rs`, `src/App.tsx` + +### 4. No Way to Open Tmux from Existing Environments +**Problem**: Users couldn't create/attach tmux windows for existing worktrees. + +**Solution**: Added purple "Tmux" button to each worktree environment card that: +- Creates tmux window if it doesn't exist +- Opens Terminal.app and attaches to the specific window +- Reuses existing windows (no duplicates) +- Shows visual feedback in activity log + +**Files**: `src/components/EnvironmentsPanel.tsx`, `src/App.tsx`, `src-tauri/src/commands/worktree.rs` + +## Architecture + +### Tmux Session Structure + +``` +workmux (session) +├── 0: zsh (default window) +├── 1: ushadow-blue (worktree window) +├── 2: ushadow-gold (worktree window) +└── 3: ushadow-purple (worktree window) +``` + +- Single persistent `workmux` session for all worktrees +- Each worktree gets its own window: `ushadow-{env-name}` +- Windows are created in the worktree's directory +- Sessions persist across launcher restarts + +### Key Commands + +#### Backend (Rust) + +**`ensure_tmux_running()`** +- Checks if tmux server is running +- Creates "workmux" session if needed +- Returns success message + +**`attach_tmux_to_worktree(worktree_path: String, env_name: String)`** +- Ensures tmux is running +- Creates window `ushadow-{env-name}` if doesn't exist +- Opens Terminal.app (macOS) and attaches to window +- Returns status message + +**`get_tmux_info()`** +- Lists all tmux sessions and windows +- Shows helpful message if no server running +- Returns formatted string for display + +**`get_environment_tmux_status(env_name: String)`** +- Returns detailed status for specific environment +- Includes: exists, window_name, current_command, activity_status +- Used for real-time status badges + +#### Frontend (TypeScript) + +**`handleAttachTmux(env: UshadowEnvironment)`** +- Called when Tmux button clicked on environment card +- Validates environment has a path +- Calls backend `attachTmuxToWorktree()` +- Refreshes discovery to update status badges +- Shows feedback in activity log + +**Auto-start Logic in `refreshDiscovery()`** +- Detects if worktrees exist +- Silently calls `ensureTmuxRunning()` if needed +- Non-intrusive, doesn't spam logs + +## User Features + +### 1. Global Tmux Button (Header) +**Location**: Environments panel header, next to "New Environment" + +**Features**: +- Click to view all tmux sessions and windows +- Shows "Start Tmux Server" button if no server running +- Displays current command for each window +- Useful for debugging and verification + +**Usage**: +``` +Click "Tmux" → See all sessions → Click "Start Tmux Server" if needed +``` + +### 2. Per-Environment Tmux Button +**Location**: Purple terminal icon on worktree environment cards + +**Features**: +- Creates/reuses tmux window for that environment +- Opens Terminal.app and attaches you to the session +- Tooltip shows "Create/attach tmux window" or "Tmux window exists" +- Works for both running and stopped environments + +**Usage**: +``` +Click purple Terminal icon → New Terminal window opens → You're in tmux session +``` + +### 3. Status Badges +**Location**: Next to branch name on environment cards + +**Indicators**: +- `🤖 Working` - Active command running (npm, docker, etc.) +- `💬 Waiting` - Shell waiting for input +- `✅ Done` - Command completed successfully +- `❌ Error` - Command exited with error + +### 4. Auto-Start on Launcher Startup +**Behavior**: +- Launcher detects existing worktrees on startup +- Silently ensures tmux server is running +- No user intervention required +- No spam in activity log + +## Technical Implementation + +### Tailscale Caching + +**File**: `src-tauri/src/commands/discovery.rs` + +```rust +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static TAILSCALE_CACHE: Mutex> = Mutex::new(None); + +pub async fn discover_environments_with_config(...) -> Result { + let tailscale_ok = { + let mut cache = TAILSCALE_CACHE.lock().unwrap(); + let now = Instant::now(); + + if let Some((cached_ok, cached_time)) = *cache { + if now.duration_since(cached_time) < Duration::from_secs(10) { + return cached_ok; // Use cached value + } + } + + // Cache miss or expired - do real check + let (installed, connected, _) = check_tailscale(); + let ok = installed && connected; + *cache = Some((ok, now)); + ok + }; + // ... +} +``` + +**Benefits**: +- Reduces 7 checks to 1 real check + 6 cached checks +- 10-second TTL balances freshness vs performance +- Thread-safe with Mutex +- Zero impact on first check + +### Terminal Opening (macOS) + +**File**: `src-tauri/src/commands/worktree.rs` + +```rust +#[cfg(target_os = "macos")] +{ + let script = format!( + "tell application \"Terminal\" to do script \"tmux attach-session -t workmux:{} && exit\"", + window_name + ); + + let open_terminal = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| format!("Failed to open Terminal: {}", e))?; +} +``` + +**How it works**: +1. Uses AppleScript via `osascript` to control Terminal.app +2. Attaches to specific window: `workmux:ushadow-{env-name}` +3. Adds `&& exit` to close Terminal when detaching from tmux +4. Non-blocking - doesn't fail entire operation if Terminal opening fails + +### Window Reuse Logic + +**File**: `src-tauri/src/commands/worktree.rs` + +```rust +// Check if window already exists +let check_window = shell_command(&format!( + "tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", + window_name +)).output(); + +let window_existed = matches!(check_window, Ok(ref output) if output.status.success()); + +// Create only if needed +if !window_existed { + let create_window = shell_command(&format!( + "tmux new-window -t workmux -n {} -c '{}'", + window_name, worktree_path + )).output()?; +} +``` + +**Benefits**: +- No duplicate windows +- Idempotent operation - safe to click multiple times +- Window persists across launcher restarts +- Can attach to existing work session + +## File Changes Summary + +### New Commands Added + +**Rust (`src-tauri/src/commands/worktree.rs`)**: +- `ensure_tmux_running()` - Auto-start tmux server +- `attach_tmux_to_worktree()` - Create/attach window and open terminal +- `get_tmux_info()` - List all sessions/windows +- Modified `create_worktree_with_workmux()` - Added auto-start logic + +**TypeScript (`src/hooks/useTauri.ts`)**: +- `getTmuxInfo()` - Wrapper for get_tmux_info +- `ensureTmuxRunning()` - Wrapper for ensure_tmux_running +- `attachTmuxToWorktree()` - Wrapper for attach_tmux_to_worktree + +### UI Components Modified + +**`src/components/EnvironmentsPanel.tsx`**: +- Added global "Tmux" button in header +- Added tmux info dialog with "Start Tmux Server" button +- Added per-environment tmux button (purple terminal icon) +- Added `onAttachTmux` callback prop + +**`src/App.tsx`**: +- Added `handleAttachTmux()` handler +- Modified `refreshDiscovery()` for auto-start +- Added tmux status check in `handleNewEnvWorktree()` +- Wired up `onAttachTmux` to EnvironmentsPanel + +### Backend Registration + +**`src-tauri/src/main.rs`**: +- Registered new commands in `invoke_handler![]` +- Imported new command functions + +## Testing Checklist + +### Auto-Start Tmux +- [ ] Stop tmux: `tmux kill-server` +- [ ] Start launcher +- [ ] Verify tmux auto-starts: `tmux list-sessions` +- [ ] Should see "workmux" session + +### Tmux Button (Per-Environment) +- [ ] Click purple terminal icon on any worktree +- [ ] Verify Terminal.app opens +- [ ] Verify you're attached to correct tmux window +- [ ] Verify working directory is worktree path +- [ ] Click button again, verify reuses existing window + +### Global Tmux Dialog +- [ ] Click "Tmux" button in header +- [ ] Verify shows all sessions and windows +- [ ] Stop tmux: `tmux kill-server` +- [ ] Click "Tmux" button again +- [ ] Verify shows "Start Tmux Server" button +- [ ] Click "Start Tmux Server" +- [ ] Verify creates workmux session + +### Fast Environment Ready Detection +- [ ] Start an environment +- [ ] Watch activity log +- [ ] Should declare ready in ~2 seconds, not 12+ +- [ ] Should NOT see 7 tailscale queries + +### Status Badges +- [ ] Create new worktree with tmux window +- [ ] Verify activity badge appears (🤖/💬/etc.) +- [ ] Run a command in tmux: `npm run dev` +- [ ] Refresh discovery +- [ ] Verify badge shows `🤖 npm` or similar + +## Known Limitations + +1. **macOS Only Terminal Opening**: The Terminal.app integration uses AppleScript and only works on macOS. Linux/Windows have placeholder code that may not work reliably. + +2. **Tmux Socket**: Uses default tmux socket at `/private/tmp/tmux-501/default`. If users have multiple tmux servers on different sockets, the launcher only sees the default one. + +3. **No Detach Prevention**: Users can detach from tmux manually, leaving the window running in background. This is by design but might be confusing. + +4. **Terminal Window Management**: Each click opens a new Terminal.app window. There's no automatic window reuse or tab creation in Terminal.app. + +## Future Enhancements + +### Possible Improvements +- [ ] iTerm2 integration option (many developers prefer iTerm) +- [ ] Configurable terminal emulator (user preference) +- [ ] Inline terminal within launcher app (embed xterm.js) +- [ ] Better tmux socket detection/configuration +- [ ] Tab-based terminal opening instead of new windows +- [ ] Tmux layout templates (split panes, predefined layouts) +- [ ] Command history per worktree +- [ ] Auto-run commands on tmux creation (npm install, etc.) + +### Architecture Considerations +- Consider migrating to workmux CLI's native integration +- Explore tauri shell plugin for cross-platform terminal support +- Evaluate embedded terminal solutions for better UX + +## Troubleshooting + +### Tmux Won't Start +**Symptom**: "Start Tmux Server" button fails + +**Solutions**: +1. Check tmux is installed: `which tmux` +2. Try manually: `tmux new-session -d -s workmux` +3. Check for hung tmux processes: `ps aux | grep tmux` +4. Kill hung processes: `pkill tmux` + +### Terminal Won't Open +**Symptom**: Clicking tmux button does nothing + +**Solutions**: +1. Check Console.app for osascript errors +2. Verify Terminal.app permissions in System Settings +3. Try manually: `osascript -e 'tell application "Terminal" to do script "echo test"'` +4. Restart launcher + +### Wrong Tmux Socket +**Symptom**: `tmux list-windows -a` shows "no server running" + +**Solutions**: +1. Find all tmux sockets: `ls -la /private/tmp/tmux-*/` +2. Attach to correct one: `tmux -S /path/to/socket attach` +3. Kill other tmux servers if needed +4. Ensure using default socket + +### Status Badges Not Updating +**Symptom**: Activity badges don't change after running commands + +**Solutions**: +1. Click refresh button in launcher +2. Wait for auto-refresh cycle (every few seconds) +3. Check tmux window name matches: `tmux list-windows -a | grep ushadow-` +4. Verify tmux command polling is working + +## Next: Vibe Kanban & Remote Management + +This tmux integration is Phase 1 of a larger vision. See [ROADMAP.md](./ROADMAP.md) for upcoming features: + +**Phase 2: Vibe Kanban Integration** +- Task-driven worktree creation +- Kanban board view in launcher +- Auto-provision environments for tasks +- Lifecycle management tied to task status + +**Phase 3: Remote Development Management** +- Unified control panel for local + remote environments +- Ushadow Agent running on remote servers +- Terminal tunneling via Tailscale +- Cloud VM provisioning automation + +**Phase 4: Advanced Features** +- Team collaboration (shared tmux sessions) +- CI/CD integration (PR previews) +- Observability (metrics, tracing) +- AI/LLM integration + +## References + +- [Ushadow Launcher Roadmap](./ROADMAP.md) - Full vision and development plan +- [Tauri Documentation](https://tauri.app/) +- [Tmux Documentation](https://github.com/tmux/tmux/wiki) +- [Workmux CLI](https://github.com/yourorg/workmux) (if applicable) +- [AppleScript Language Guide](https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/) diff --git a/ushadow/launcher/public/iterm-icon.png b/ushadow/launcher/public/iterm-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..01821f9cbcd937f1fafc6b1e9ef59f34c51ab9ec GIT binary patch literal 37505 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 Result { + // Load prerequisites config + let config = PrerequisitesConfig::load()?; + + // Get current platform + let platform = get_current_platform(); + + // Find installation method for this prerequisite on this platform + let installation_methods = config.installation_methods + .ok_or_else(|| format!("No installation methods defined in config"))?; + + let prereq_methods = installation_methods.get(&prerequisite_id) + .ok_or_else(|| format!("No installation method found for '{}'", prerequisite_id))?; + + let method = prereq_methods.get(&platform) + .ok_or_else(|| format!("No installation method for '{}' on platform '{}'", prerequisite_id, platform))?; + + // Execute the installation based on method type + execute_installation(&prerequisite_id, method, &platform).await +} + +/// Get the start command for a service prerequisite +#[tauri::command] +pub async fn start_prerequisite(prerequisite_id: String) -> Result { + // Load prerequisites config + let config = PrerequisitesConfig::load()?; + + // Get the prerequisite definition + let prereq = config.get_prerequisite(&prerequisite_id) + .ok_or_else(|| format!("Prerequisite '{}' not found", prerequisite_id))?; + + // Check if this prerequisite has a service + if !prereq.has_service.unwrap_or(false) { + return Err(format!("'{}' is not a service that can be started", prerequisite_id)); + } + + // Platform-specific start logic + let platform = get_current_platform(); + match (prerequisite_id.as_str(), platform.as_str()) { + ("docker", "macos") => start_docker_macos().await, + ("docker", "windows") => start_docker_windows().await, + ("docker", "linux") => start_docker_linux().await, + _ => Err(format!("Start not implemented for '{}' on '{}'", prerequisite_id, platform)) + } +} + +/// Execute installation based on method type +async fn execute_installation( + prereq_id: &str, + method: &InstallationMethod, + platform: &str, +) -> Result { + match method.method.as_str() { + "homebrew" => install_via_homebrew(prereq_id, method).await, + "winget" => install_via_winget(prereq_id, method).await, + "download" => install_via_download(prereq_id, method).await, + "script" => install_via_script(prereq_id, method).await, + "package_manager" => install_via_package_manager(prereq_id, method).await, + "cargo" => install_via_cargo(prereq_id, method).await, + _ => Err(format!("Unknown installation method: {}", method.method)) + } +} + +/// Install via Homebrew (macOS) +async fn install_via_homebrew(prereq_id: &str, method: &InstallationMethod) -> Result { + if !check_brew_installed() { + return Err("Homebrew is not installed".to_string()); + } + + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for Homebrew installation".to_string())?; + + let brew_path = get_brew_path(); + + // Determine if this is a cask or formula + let is_cask = prereq_id == "docker" || prereq_id == "tailscale"; + + let args = if is_cask { + vec!["install", "--cask", package] + } else { + vec!["install", package] + }; + + eprintln!("Installing {} via Homebrew: {} {}", prereq_id, brew_path, args.join(" ")); + + // For apps that require admin privileges (like Docker), use osascript + if prereq_id == "docker" { + let script = format!( + r#"do shell script "{} install --cask {}" with administrator privileges"#, + brew_path, package + ); + + let output = Command::new("osascript") + .args(["-e", &script]) + .output() + .map_err(|e| format!("Failed to run osascript: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via Homebrew", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("-128") { + Err("Installation cancelled by user".to_string()) + } else { + Err(format!("Homebrew install failed: {}", stderr)) + } + } + } else { + // For other packages, run brew directly + let output = silent_command(&brew_path) + .args(&args) + .output() + .map_err(|e| format!("Failed to run brew: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via Homebrew", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Homebrew install failed: {}", stderr)) + } + } +} + +/// Install via winget (Windows) +async fn install_via_winget(prereq_id: &str, method: &InstallationMethod) -> Result { + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for winget installation".to_string())?; + + eprintln!("Installing {} via winget: {}", prereq_id, package); + + let output = silent_command("winget") + .args([ + "install", + "--id", package, + "-e", + "--source", "winget", + "--accept-package-agreements", + "--accept-source-agreements" + ]) + .output() + .map_err(|e| format!("Failed to run winget: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via winget", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("winget install failed: {}", stderr)) + } +} + +/// Install via download (opens URL for manual download) +async fn install_via_download(prereq_id: &str, method: &InstallationMethod) -> Result { + let url = method.url.as_ref() + .ok_or_else(|| "No URL specified for download installation".to_string())?; + + eprintln!("Opening download URL for {}: {}", prereq_id, url); + + // Special handling for Homebrew - download and open .pkg + if prereq_id == "homebrew" { + let pkg_url = "https://github.com/Homebrew/brew/releases/download/5.0.9/Homebrew-5.0.9.pkg"; + let tmp_dir = std::env::temp_dir(); + let pkg_path = tmp_dir.join("Homebrew-5.0.9.pkg"); + + // Download the pkg file + let response = reqwest::get(pkg_url) + .await + .map_err(|e| format!("Failed to download installer: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Failed to download installer: HTTP {}", response.status())); + } + + let bytes = response.bytes() + .await + .map_err(|e| format!("Failed to read installer data: {}", e))?; + + std::fs::write(&pkg_path, bytes) + .map_err(|e| format!("Failed to save installer: {}", e))?; + + // Open the .pkg file + Command::new("open") + .arg(&pkg_path) + .output() + .map_err(|e| format!("Failed to open installer: {}", e))?; + + return Ok("Installer opened. Please follow the prompts to complete installation.".to_string()); + } + + // For other downloads, just open the URL in browser + open::that(url) + .map_err(|e| format!("Failed to open URL: {}", e))?; + + Ok(format!("Opening download page for {}. Please follow the installation instructions.", prereq_id)) +} + +/// Install via script (download and execute installation script) +async fn install_via_script(prereq_id: &str, method: &InstallationMethod) -> Result { + let url = method.url.as_ref() + .ok_or_else(|| "No URL specified for script installation".to_string())?; + + eprintln!("Installing {} via script: {}", prereq_id, url); + + // Download script + let response = reqwest::get(url) + .await + .map_err(|e| format!("Failed to download installation script: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Failed to download script: HTTP {}", response.status())); + } + + let script_content = response.text() + .await + .map_err(|e| format!("Failed to read script: {}", e))?; + + // Save script to temp file + let tmp_dir = std::env::temp_dir(); + let script_path = tmp_dir.join(format!("install_{}.sh", prereq_id)); + std::fs::write(&script_path, script_content) + .map_err(|e| format!("Failed to save script: {}", e))?; + + // Make executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&script_path) + .map_err(|e| format!("Failed to get script metadata: {}", e))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script_path, permissions) + .map_err(|e| format!("Failed to set script permissions: {}", e))?; + } + + // Execute script + let output = shell_command(&format!("bash {}", script_path.display())) + .output() + .map_err(|e| format!("Failed to execute script: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via script", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Script installation failed: {}", stderr)) + } +} + +/// Install via package manager (apt, yum, dnf) +async fn install_via_package_manager(prereq_id: &str, method: &InstallationMethod) -> Result { + let packages = method.packages.as_ref() + .ok_or_else(|| "No packages specified for package manager installation".to_string())?; + + // Detect package manager + let (pkg_mgr, package) = if let Some(pkg) = packages.get("apt") { + ("apt", pkg) + } else if let Some(pkg) = packages.get("yum") { + ("yum", pkg) + } else if let Some(pkg) = packages.get("dnf") { + ("dnf", pkg) + } else { + return Err("No supported package manager found".to_string()); + }; + + eprintln!("Installing {} via {}: {}", prereq_id, pkg_mgr, package); + + let args = match pkg_mgr { + "apt" => vec!["install", "-y", package], + "yum" | "dnf" => vec!["install", "-y", package], + _ => return Err(format!("Unsupported package manager: {}", pkg_mgr)) + }; + + let output = Command::new("sudo") + .arg(pkg_mgr) + .args(&args) + .output() + .map_err(|e| format!("Failed to run {}: {}", pkg_mgr, e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via {}", prereq_id, pkg_mgr)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("{} install failed: {}", pkg_mgr, stderr)) + } +} + +/// Install via cargo +async fn install_via_cargo(prereq_id: &str, method: &InstallationMethod) -> Result { + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for cargo installation".to_string())?; + + eprintln!("Installing {} via cargo: {}", prereq_id, package); + + let output = shell_command(&format!("cargo install {}", package)) + .output() + .map_err(|e| format!("Failed to run cargo: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via cargo", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("cargo install failed: {}", stderr)) + } +} + +/// Start Docker on macOS +async fn start_docker_macos() -> Result { + Command::new("open") + .args(["-a", "Docker"]) + .output() + .map_err(|e| format!("Failed to open Docker Desktop: {}", e))?; + + Ok("Docker Desktop starting...".to_string()) +} + +/// Start Docker on Windows +async fn start_docker_windows() -> Result { + use std::path::Path; + + let paths = vec![ + r"C:\Program Files\Docker\Docker\Docker Desktop.exe", + r"C:\Program Files\Docker\Docker Desktop.exe", + ]; + + for path in paths { + if Path::new(path).exists() { + Command::new(path) + .spawn() + .map_err(|e| format!("Failed to start Docker Desktop: {}", e))?; + + return Ok("Docker Desktop starting...".to_string()); + } + } + + Err("Docker Desktop.exe not found".to_string()) +} + +/// Start Docker on Linux +async fn start_docker_linux() -> Result { + // Try systemctl first + let systemctl_output = Command::new("systemctl") + .args(["start", "docker"]) + .output(); + + if let Ok(output) = systemctl_output { + if output.status.success() { + return Ok("Docker service started via systemctl".to_string()); + } + } + + // Fallback to service command + let service_output = Command::new("service") + .args(["docker", "start"]) + .output(); + + if let Ok(output) = service_output { + if output.status.success() { + return Ok("Docker service started via service command".to_string()); + } + } + + Err("Failed to start Docker service. Try: sudo systemctl start docker".to_string()) +} + +/// Get current platform string +fn get_current_platform() -> String { + #[cfg(target_os = "macos")] + return "macos".to_string(); + + #[cfg(target_os = "windows")] + return "windows".to_string(); + + #[cfg(target_os = "linux")] + return "linux".to_string(); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + return "unknown".to_string(); +} diff --git a/ushadow/launcher/src-tauri/src/commands/mod.rs b/ushadow/launcher/src-tauri/src/commands/mod.rs index 781a4974..73d54f2a 100644 --- a/ushadow/launcher/src-tauri/src/commands/mod.rs +++ b/ushadow/launcher/src-tauri/src/commands/mod.rs @@ -1,7 +1,9 @@ mod docker; mod discovery; mod prerequisites; +mod prerequisites_config; mod installer; +mod generic_installer; mod utils; mod permissions; mod settings; @@ -12,7 +14,9 @@ pub mod worktree; pub use docker::*; pub use discovery::*; pub use prerequisites::*; +pub use prerequisites_config::*; pub use installer::*; +pub use generic_installer::*; pub use permissions::*; pub use settings::*; pub use worktree::*; diff --git a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs index 96be7ecd..aeb26bc3 100644 --- a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs +++ b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs @@ -1,5 +1,11 @@ use crate::models::PrerequisiteStatus; use super::utils::{silent_command, shell_command}; +use std::env; + +/// Check if we're in mock mode (for testing) +fn is_mock_mode() -> bool { + env::var("MOCK_PREREQUISITES").is_ok() +} /// Check if Docker is installed and running /// Tries login shell first, then falls back to known paths diff --git a/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs b/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs new file mode 100644 index 00000000..52a6ecb3 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// A single prerequisite definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PrerequisiteDefinition { + pub id: String, + pub name: String, + pub display_name: String, + pub description: String, + pub platforms: Vec, + pub check_command: Option, + pub check_commands: Option>, + pub check_running_command: Option, + pub check_connected_command: Option, + pub fallback_paths: Option>, + pub version_filter: Option, + pub optional: bool, + pub has_service: Option, + pub category: String, + #[serde(skip)] + pub platform_specific_paths: Option>>, + pub connection_validation: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConnectionValidation { + pub starts_with: Option, +} + +/// Installation method definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallationMethod { + pub method: String, + pub package: Option, + pub url: Option, + pub packages: Option>, +} + +/// Root configuration structure +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PrerequisitesConfig { + pub prerequisites: Vec, + pub installation_methods: Option>>, +} + +impl PrerequisitesConfig { + /// Load prerequisites configuration from YAML file + pub fn load() -> Result { + // Get the path to the prerequisites.yaml file + // In development: src-tauri/prerequisites.yaml + // In production: resources/prerequisites.yaml (bundled with app) + let config_path = Self::get_config_path()?; + + let yaml_content = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read prerequisites config at {:?}: {}", config_path, e))?; + + let mut config: PrerequisitesConfig = serde_yaml::from_str(&yaml_content) + .map_err(|e| format!("Failed to parse prerequisites config: {}", e))?; + + // Post-process: extract platform-specific paths + for prereq in &mut config.prerequisites { + if let Some(_fallback_paths) = &prereq.fallback_paths { + // Check if this is a map-like structure in the YAML + // For now, keep it simple and use the Vec structure + // In a future enhancement, we could parse platform-specific paths differently + } + } + + Ok(config) + } + + /// Get the path to the prerequisites config file + fn get_config_path() -> Result { + // Try development path first (during development/testing) + let dev_path = PathBuf::from("prerequisites.yaml"); + if dev_path.exists() { + return Ok(dev_path); + } + + // Try relative to src-tauri directory + let src_tauri_path = PathBuf::from("src-tauri/prerequisites.yaml"); + if src_tauri_path.exists() { + return Ok(src_tauri_path); + } + + // Try parent directory (when running from src-tauri/target/debug) + let parent_path = PathBuf::from("../prerequisites.yaml"); + if parent_path.exists() { + return Ok(parent_path); + } + + // Try two levels up + let parent2_path = PathBuf::from("../../prerequisites.yaml"); + if parent2_path.exists() { + return Ok(parent2_path); + } + + // In production, try the resources directory + // Note: This will be available when the app is built and packaged + // For now, we rely on the development paths above + + Err(format!( + "Could not find prerequisites.yaml. Tried:\n - {:?}\n - {:?}\n - {:?}\n - {:?}", + dev_path, src_tauri_path, parent_path, parent2_path + )) + } + + /// Get a prerequisite definition by ID + pub fn get_prerequisite(&self, id: &str) -> Option<&PrerequisiteDefinition> { + self.prerequisites.iter().find(|p| p.id == id) + } + + /// Get all prerequisites for the current platform + pub fn get_platform_prerequisites(&self, platform: &str) -> Vec<&PrerequisiteDefinition> { + self.prerequisites + .iter() + .filter(|p| p.platforms.contains(&platform.to_string())) + .collect() + } +} + +/// Tauri command to get prerequisites configuration +#[tauri::command] +pub fn get_prerequisites_config() -> Result { + PrerequisitesConfig::load() +} + +/// Tauri command to get prerequisites for current platform +#[tauri::command] +pub fn get_platform_prerequisites_config(platform: String) -> Result, String> { + let config = PrerequisitesConfig::load()?; + let prereqs = config.get_platform_prerequisites(&platform); + Ok(prereqs.into_iter().cloned().collect()) +} diff --git a/ushadow/launcher/src-tauri/src/main.rs b/ushadow/launcher/src-tauri/src/main.rs index a01bcff9..85e9cebb 100644 --- a/ushadow/launcher/src-tauri/src/main.rs +++ b/ushadow/launcher/src-tauri/src/main.rs @@ -29,6 +29,10 @@ use commands::{AppState, check_prerequisites, discover_environments, get_os_type open_tmux_in_terminal, capture_tmux_pane, get_claude_status, // Settings load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, + // Prerequisites config + get_prerequisites_config, get_platform_prerequisites_config, + // Generic installer + install_prerequisite, start_prerequisite, // Permissions check_install_path}; use tauri::{ @@ -174,6 +178,12 @@ fn main() { load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, + // Prerequisites config + get_prerequisites_config, + get_platform_prerequisites_config, + // Generic installer + install_prerequisite, + start_prerequisite, ]) .setup(|app| { let window = app.get_window("main").unwrap(); From 54a03bfdc70ec40e16147a6d7caaa96c3f709d90 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Thu, 22 Jan 2026 13:44:23 +0000 Subject: [PATCH 05/70] split pages into sections --- ushadow/launcher/dist/index.html | 4 +- ushadow/launcher/src/App.tsx | 187 ++- .../src/components/EnvironmentsPanel.tsx | 1136 +++++++++-------- ushadow/launcher/src/store/appStore.ts | 4 +- 4 files changed, 765 insertions(+), 566 deletions(-) diff --git a/ushadow/launcher/dist/index.html b/ushadow/launcher/dist/index.html index 8f567827..3ed998f8 100644 --- a/ushadow/launcher/dist/index.html +++ b/ushadow/launcher/dist/index.html @@ -5,8 +5,8 @@ Ushadow Launcher - - + +
diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index f78885db..a2f9ba3f 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -15,7 +15,7 @@ import { NewEnvironmentDialog } from './components/NewEnvironmentDialog' import { TmuxManagerDialog } from './components/TmuxManagerDialog' import { SettingsDialog } from './components/SettingsDialog' import { EmbeddedView } from './components/EmbeddedView' -import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders } from 'lucide-react' +import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders, Package, FolderGit2 } from 'lucide-react' import { getColors } from './utils/colors' function App() { @@ -1292,25 +1292,37 @@ function App() {
- {/* Mode Toggle */} + {/* Page Navigation */}
+
@@ -1349,8 +1361,8 @@ function App() { {/* Main Content */}
- {appMode === 'quick' ? ( - /* Quick Mode - Single button */ + {appMode === 'launch' ? ( + /* Launch Page - One-Click Launch */

One-Click Launch

@@ -1379,8 +1391,8 @@ function App() { onClick={handleQuickLaunch} disabled={isLaunching} className={`px-12 py-4 rounded-xl transition-all font-semibold text-lg flex items-center justify-center gap-3 ${ - isLaunching - ? 'bg-surface-600 cursor-not-allowed' + isLaunching + ? 'bg-surface-600 cursor-not-allowed' : 'bg-gradient-brand hover:opacity-90 hover:shadow-lg hover:shadow-primary-500/20 active:scale-95' }`} data-testid="quick-launch-button" @@ -1398,20 +1410,25 @@ function App() { )}
- ) : ( - /* Dev Mode - Two column layout */ -
-
- {/* Left Column - Folders, Prerequisites & Infrastructure */} -
- setShowProjectDialog(true)} - /> + ) : appMode === 'install' ? ( + /* Install Page - Prerequisites & Infrastructure Setup */ +
+
+

Setup & Installation

+

+ Install prerequisites and configure your single environment +

+
+ + setShowProjectDialog(true)} + /> + + {/* Prerequisites and Infrastructure Side-by-Side */} +
+
{/* Dev Tools Panel - appears below Prerequisites */} {showDevTools && } -
- {/* Resize handle */} -
+ +
- {/* Right Column - Environments */} -
- setShowNewEnvDialog(true)} - onOpenInApp={handleOpenInApp} - onMerge={handleMerge} - onDelete={handleDelete} - onAttachTmux={handleAttachTmux} - onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} - loadingEnv={loadingEnv} - tmuxStatuses={tmuxStatuses} - /> -
+ {/* Single Environment Section for Consumers */} +
+

Your Environment

+ {discovery?.environments.length === 0 ? ( +
+

No environment created yet

+ +
+ ) : ( +
+ {discovery.environments.map(env => ( +
+
+
+ {env.name} + + {env.status} + +
+
+ {env.running ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ )}
+ ) : ( + /* Environments Page - Worktree Management */ +
+ setShowNewEnvDialog(true)} + onOpenInApp={handleOpenInApp} + onMerge={handleMerge} + onDelete={handleDelete} + onAttachTmux={handleAttachTmux} + onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} + loadingEnv={loadingEnv} + tmuxStatuses={tmuxStatuses} + /> +
)}
diff --git a/ushadow/launcher/src/components/EnvironmentsPanel.tsx b/ushadow/launcher/src/components/EnvironmentsPanel.tsx index a400d56c..4faa65ba 100644 --- a/ushadow/launcher/src/components/EnvironmentsPanel.tsx +++ b/ushadow/launcher/src/components/EnvironmentsPanel.tsx @@ -1,9 +1,8 @@ import { useState, useEffect } from 'react' -import { Plus, Play, Square, Settings, Loader2, AppWindow, Box, FolderOpen, X, AlertCircle, GitBranch, GitMerge, Trash2, Terminal, ChevronDown, ChevronUp, Bot } from 'lucide-react' -import type { UshadowEnvironment, TmuxStatus, ClaudeStatus } from '../hooks/useTauri' +import { Plus, Play, Square, Settings, Loader2, AppWindow, Box, X, AlertCircle, GitMerge, Terminal, FolderOpen, ArrowLeft } from 'lucide-react' +import type { UshadowEnvironment, TmuxStatus } from '../hooks/useTauri' import { tauri } from '../hooks/useTauri' import { getColors } from '../utils/colors' -import { getTmuxStatusIcon, getTmuxStatusText } from '../hooks/useTmuxMonitoring' import { TmuxManagerDialog } from './TmuxManagerDialog' interface CreatingEnv { @@ -44,6 +43,10 @@ export function EnvironmentsPanel({ }: EnvironmentsPanelProps) { const [activeTab, setActiveTab] = useState<'running' | 'detected'>('running') const [showTmuxManager, setShowTmuxManager] = useState(false) + const [selectedEnv, setSelectedEnv] = useState(null) + const [showBrowserView, setShowBrowserView] = useState(false) + const [leftColumnWidth, setLeftColumnWidth] = useState(320) + const [isResizing, setIsResizing] = useState(false) // Sort environments: worktrees first, then reverse to show newest first const sortedEnvironments = [...environments].sort((a, b) => { @@ -56,115 +59,216 @@ export function EnvironmentsPanel({ const runningEnvs = sortedEnvironments.filter(env => env.running) const stoppedEnvs = sortedEnvironments.filter(env => !env.running) + // Auto-select first running environment if none selected + if (!selectedEnv && runningEnvs.length > 0) { + setSelectedEnv(runningEnvs[0]) + } + + // Resize handlers + const handleMouseDown = (e: React.MouseEvent) => { + setIsResizing(true) + e.preventDefault() + } + + const handleMouseMove = (e: MouseEvent) => { + if (isResizing) { + const newWidth = e.clientX - 16 // Account for padding + if (newWidth >= 250 && newWidth <= 500) { + setLeftColumnWidth(newWidth) + } + } + } + + const handleMouseUp = () => { + setIsResizing(false) + } + + // Set up mouse event listeners + useEffect(() => { + if (isResizing) { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + } + }, [isResizing]) + + // Auto-open browser view when selecting running environment + useEffect(() => { + if (selectedEnv) { + setShowBrowserView(selectedEnv.running) + } + }, [selectedEnv?.name, selectedEnv?.running]) + + // Handle environment selection + const handleEnvSelect = (env: UshadowEnvironment) => { + setSelectedEnv(env) + } + + // Handle opening in browser view + const handleOpenInBrowser = () => { + setShowBrowserView(true) + } + return ( -
-
-

Ushadow Environments

-
- - +
+ {/* Left Column - Environment Cards */} +
+
+
+

Environments

+
+ + +
+
+ + {/* Tabs */} +
+ + +
+ + {/* Creating Environments - always show at top */} + {creatingEnvs.length > 0 && ( +
+ {creatingEnvs.map((env) => ( + onDismissError(env.name) : undefined} + /> + ))} +
+ )} + + {/* Environment Cards */} +
+ {activeTab === 'running' ? ( + runningEnvs.length === 0 && creatingEnvs.length === 0 ? ( + 0} /> + ) : ( + runningEnvs.map((env) => ( + onStart(env.name)} + onStop={() => onStop(env.name)} + onOpenInApp={() => onOpenInApp(env)} + isLoading={loadingEnv === env.name} + isSelected={selectedEnv?.name === env.name} + onSelect={() => handleEnvSelect(env)} + /> + )) + ) + ) : ( + stoppedEnvs.length === 0 ? ( + + ) : ( + stoppedEnvs.map((env) => ( + onStart(env.name)} + onStop={() => onStop(env.name)} + onOpenInApp={() => onOpenInApp(env)} + isLoading={loadingEnv === env.name} + isSelected={selectedEnv?.name === env.name} + onSelect={() => handleEnvSelect(env)} + /> + )) + ) + )} +
- {/* Tabs */} -
- - -
+ {/* Resize handle */} +
- {/* Creating Environments - always show at top */} - {creatingEnvs.length > 0 && ( -
- {creatingEnvs.map((env) => ( - onDismissError(env.name) : undefined} + {/* Right Column - Detail Panel or Browser View */} +
+ {selectedEnv ? ( + showBrowserView && selectedEnv.running ? ( + setShowBrowserView(false)} + onStop={() => onStop(selectedEnv.name)} + isLoading={loadingEnv === selectedEnv.name} + tmuxStatus={tmuxStatuses[selectedEnv.name]} /> - ))} -
- )} - - {/* Tab Content */} - {activeTab === 'running' ? ( - runningEnvs.length === 0 && creatingEnvs.length === 0 ? ( - 0} /> - ) : ( -
- {runningEnvs.map((env) => ( - onStart(env.name)} - onStop={() => onStop(env.name)} - onOpenInApp={() => onOpenInApp(env)} - onMerge={onMerge ? () => onMerge(env.name) : undefined} - onDelete={onDelete ? () => onDelete(env.name) : undefined} - onAttachTmux={onAttachTmux ? () => onAttachTmux(env) : undefined} - isLoading={loadingEnv === env.name} - tmuxStatus={tmuxStatuses[env.name]} + ) : ( +
+ onStart(selectedEnv.name)} + onStop={() => onStop(selectedEnv.name)} + onOpenInApp={handleOpenInBrowser} + onMerge={onMerge ? () => onMerge(selectedEnv.name) : undefined} + onDelete={onDelete ? () => onDelete(selectedEnv.name) : undefined} + onAttachTmux={onAttachTmux ? () => onAttachTmux(selectedEnv) : undefined} + isLoading={loadingEnv === selectedEnv.name} + tmuxStatus={tmuxStatuses[selectedEnv.name]} /> - ))} -
- ) - ) : ( - stoppedEnvs.length === 0 ? ( - +
+ ) ) : ( -
- {stoppedEnvs.map((env) => ( - onStart(env.name)} - onStop={() => onStop(env.name)} - onOpenInApp={() => onOpenInApp(env)} - onMerge={onMerge ? () => onMerge(env.name) : undefined} - onDelete={onDelete ? () => onDelete(env.name) : undefined} - onAttachTmux={onAttachTmux ? () => onAttachTmux(env) : undefined} - isLoading={loadingEnv === env.name} - tmuxStatus={tmuxStatuses[env.name]} - /> - ))} +
+
+ +

Select an environment to view details

+
- ) - )} + )} +
{/* Tmux Manager Dialog */} void }) { ) } +interface BrowserViewProps { + environment: UshadowEnvironment + onClose: () => void + onStop: () => void + isLoading: boolean + tmuxStatus?: TmuxStatus +} + +function BrowserView({ environment, onClose, onStop, isLoading, tmuxStatus }: BrowserViewProps) { + const colors = getColors(environment.color || environment.name) + const url = environment.localhost_url || (environment.backend_port ? `http://localhost:${environment.webui_port || environment.backend_port}` : '') + + const handleOpenVscode = () => { + if (environment.path) { + tauri.openInVscode(environment.path, environment.name) + } + } + + const handleOpenTerminal = async () => { + if (environment.path) { + const windowName = `ushadow-${environment.name}` + await tauri.openTmuxInTerminal(windowName, environment.path) + } + } + + const handleOpenInNewTab = () => { + if (url) { + window.open(url, '_blank') + } + } + + return ( +
+ {/* Enhanced Header */} +
+ {/* Top Row - Environment Info */} +
+
+ +
+
+ + {environment.name} + + {environment.is_worktree && ( + + Worktree + + )} +
+
+ + {/* Action Buttons */} +
+ + {environment.path && ( + <> + + {environment.is_worktree && ( + + )} + + )} + +
+
+ + {/* Bottom Row - URL and Details */} +
+
+ {url} + {environment.branch && ( + + Branch: {environment.branch} + + )} +
+
+ {environment.backend_port && ( + Backend: {environment.backend_port} + )} + {environment.webui_port && ( + WebUI: {environment.webui_port} + )} +
+
+
+ + {/* iframe */} +
+