Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ src-tauri/
- Selecting a thread always calls `thread/resume` to refresh messages from disk.
- CLI sessions appear if their `cwd` matches the workspace path; they are not live-streamed unless resumed.
- The app uses `codex app-server` over stdio; see `src-tauri/src/lib.rs`.
- Worktree agents live in `.codex-worktrees/` and are removed on delete; the root repo gets a `.gitignore` entry.
- Codex sessions use the default Codex home (usually `~/.codex`); if a legacy `.codexmonitor/` exists in a workspace, it is used for that workspace.
- Worktree agents live under the app data directory (`worktrees/<workspace-id>`); legacy `.codex-worktrees/` paths remain supported, and the app no longer edits repo `.gitignore` files.
- UI state (panel sizes, reduced transparency toggle, recent thread activity) is stored in `localStorage`.
- Custom prompts load from `$CODEX_HOME/prompts` (or `~/.codex/prompts`) with optional frontmatter description/argument hints.

Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/backend/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use std::io::ErrorKind;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -178,6 +178,7 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
default_codex_bin: Option<String>,
client_version: String,
event_sink: E,
codex_home: Option<PathBuf>,
) -> Result<Arc<WorkspaceSession>, String> {
let codex_bin = entry
.codex_bin
Expand All @@ -189,6 +190,9 @@ pub(crate) async fn spawn_workspace_session<E: EventSink>(
let mut command = build_codex_command_with_bin(codex_bin);
command.current_dir(&entry.path);
command.arg("app-server");
if let Some(codex_home) = codex_home {
command.env("CODEX_HOME", codex_home);
}
command.stdin(std::process::Stdio::piped());
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
Expand Down
11 changes: 10 additions & 1 deletion src-tauri/src/codex.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use serde_json::{json, Map, Value};
use std::io::ErrorKind;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -20,10 +21,18 @@ pub(crate) async fn spawn_workspace_session(
entry: WorkspaceEntry,
default_codex_bin: Option<String>,
app_handle: AppHandle,
codex_home: Option<PathBuf>,
) -> Result<Arc<WorkspaceSession>, String> {
let client_version = app_handle.package_info().version.to_string();
let event_sink = TauriEventSink::new(app_handle);
spawn_workspace_session_inner(entry, default_codex_bin, client_version, event_sink).await
spawn_workspace_session_inner(
entry,
default_codex_bin,
client_version,
event_sink,
codex_home,
)
.await
}

#[tauri::command]
Expand Down
67 changes: 38 additions & 29 deletions src-tauri/src/workspaces.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use std::io::Write;
use std::path::PathBuf;

use ignore::WalkBuilder;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Manager, State};
use tokio::process::Command;
use uuid::Uuid;

Expand All @@ -14,6 +13,22 @@ use crate::types::{
};
use crate::utils::normalize_git_path;

fn resolve_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option<PathBuf> {
if entry.kind.is_worktree() {
if let Some(parent_path) = parent_path {
let legacy_home = PathBuf::from(parent_path).join(".codexmonitor");
if legacy_home.is_dir() {
return Some(legacy_home);
}
}
}
let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor");
if legacy_home.is_dir() {
return Some(legacy_home);
}
None
}

fn should_skip_dir(name: &str) -> bool {
matches!(
name,
Expand Down Expand Up @@ -140,27 +155,6 @@ fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf {
candidate
}

fn ensure_worktree_ignored(repo_path: &PathBuf) -> Result<(), String> {
let ignore_path = repo_path.join(".gitignore");
let entry = ".codex-worktrees/";
let existing = std::fs::read_to_string(&ignore_path).unwrap_or_default();
if existing.lines().any(|line| line.trim() == entry) {
return Ok(());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&ignore_path)
.map_err(|e| format!("Failed to update .gitignore: {e}"))?;
if !existing.ends_with('\n') && !existing.is_empty() {
file.write_all(b"\n")
.map_err(|e| format!("Failed to update .gitignore: {e}"))?;
}
file.write_all(format!("{entry}\n").as_bytes())
.map_err(|e| format!("Failed to update .gitignore: {e}"))?;
Ok(())
}

#[tauri::command]
pub(crate) async fn list_workspaces(
state: State<'_, AppState>,
Expand Down Expand Up @@ -212,7 +206,8 @@ pub(crate) async fn add_workspace(
let settings = state.app_settings.lock().await;
settings.codex_bin.clone()
};
let session = spawn_workspace_session(entry.clone(), default_bin, app).await?;
let codex_home = resolve_codex_home(&entry, None);
let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?;
{
let mut workspaces = state.workspaces.lock().await;
workspaces.insert(entry.id.clone(), entry.clone());
Expand Down Expand Up @@ -262,10 +257,14 @@ pub(crate) async fn add_worktree(
return Err("Cannot create a worktree from another worktree.".to_string());
}

let worktree_root = PathBuf::from(&parent_entry.path).join(".codex-worktrees");
let worktree_root = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to resolve app data dir: {e}"))?
.join("worktrees")
.join(&parent_entry.id);
std::fs::create_dir_all(&worktree_root)
.map_err(|e| format!("Failed to create worktree directory: {e}"))?;
ensure_worktree_ignored(&PathBuf::from(&parent_entry.path))?;

let safe_name = sanitize_worktree_name(branch);
let worktree_path = unique_worktree_path(&worktree_root, &safe_name);
Expand Down Expand Up @@ -303,7 +302,8 @@ pub(crate) async fn add_worktree(
let settings = state.app_settings.lock().await;
settings.codex_bin.clone()
};
let session = spawn_workspace_session(entry.clone(), default_bin, app).await?;
let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path));
let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?;
{
let mut workspaces = state.workspaces.lock().await;
workspaces.insert(entry.id.clone(), entry.clone());
Expand Down Expand Up @@ -511,19 +511,28 @@ pub(crate) async fn connect_workspace(
state: State<'_, AppState>,
app: AppHandle,
) -> Result<(), String> {
let entry = {
let (entry, parent_path) = {
let workspaces = state.workspaces.lock().await;
workspaces
.get(&id)
.cloned()
.map(|entry| {
let parent_path = entry
.parent_id
.as_ref()
.and_then(|parent_id| workspaces.get(parent_id))
.map(|parent| parent.path.clone());
(entry, parent_path)
})
.ok_or("workspace not found")?
};

let default_bin = {
let settings = state.app_settings.lock().await;
settings.codex_bin.clone()
};
let session = spawn_workspace_session(entry.clone(), default_bin, app).await?;
let codex_home = resolve_codex_home(&entry, parent_path.as_deref());
let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?;
state.sessions.lock().await.insert(entry.id, session);
Ok(())
}
Expand Down