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
5 changes: 4 additions & 1 deletion src-tauri/src/bin/codex_monitor_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,14 @@ impl DaemonState {
&self,
parent_id: String,
branch: String,
name: Option<String>,
client_version: String,
) -> Result<WorkspaceInfo, String> {
let client_version = client_version.clone();
workspaces_core::add_worktree_core(
parent_id,
branch,
name,
&self.data_dir,
&self.workspaces,
&self.sessions,
Expand Down Expand Up @@ -986,8 +988,9 @@ async fn handle_rpc_request(
"add_worktree" => {
let parent_id = parse_string(&params, "parentId")?;
let branch = parse_string(&params, "branch")?;
let name = parse_optional_string(&params, "name");
let workspace = state
.add_worktree(parent_id, branch, client_version)
.add_worktree(parent_id, branch, name, client_version)
.await?;
serde_json::to_value(workspace).map_err(|err| err.to_string())
}
Expand Down
10 changes: 8 additions & 2 deletions src-tauri/src/shared/workspaces_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ pub(crate) async fn add_worktree_core<
>(
parent_id: String,
branch: String,
name: Option<String>,
data_dir: &PathBuf,
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
Expand Down Expand Up @@ -275,6 +276,9 @@ where
if branch.is_empty() {
return Err("Branch name is required.".to_string());
}
let name = name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());

let parent_entry = {
let workspaces = workspaces.lock().await;
Expand Down Expand Up @@ -335,7 +339,7 @@ where

let entry = WorkspaceEntry {
id: Uuid::new_v4().to_string(),
name: branch.clone(),
name: name.clone().unwrap_or_else(|| branch.clone()),
path: worktree_path_string,
codex_bin: parent_entry.codex_bin.clone(),
kind: WorkspaceKind::Worktree,
Expand Down Expand Up @@ -697,7 +701,9 @@ where
Some(entry) => entry,
None => return Err("workspace not found".to_string()),
};
entry.name = final_branch.clone();
if entry.name.trim() == old_branch {
entry.name = final_branch.clone();
}
entry.path = next_path_string.clone();
match entry.worktree.as_mut() {
Some(worktree) => {
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/workspaces/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ pub(crate) async fn add_clone(
pub(crate) async fn add_worktree(
parent_id: String,
branch: String,
name: Option<String>,
state: State<'_, AppState>,
app: AppHandle,
) -> Result<WorkspaceInfo, String> {
Expand All @@ -292,7 +293,7 @@ pub(crate) async fn add_worktree(
&*state,
app,
"add_worktree",
json!({ "parentId": parent_id, "branch": branch }),
json!({ "parentId": parent_id, "branch": branch, "name": name }),
)
.await?;
return serde_json::from_value(response).map_err(|err| err.to_string());
Expand All @@ -306,6 +307,7 @@ pub(crate) async fn add_worktree(
workspaces_core::add_worktree_core(
parent_id,
branch,
name,
&data_dir,
&state.workspaces,
&state.sessions,
Expand Down
151 changes: 150 additions & 1 deletion src-tauri/src/workspaces/tests.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;

use super::settings::{apply_workspace_settings_update, sort_workspaces};
use super::worktree::{
build_clone_destination_path, sanitize_clone_dir_name, sanitize_worktree_name,
};
use crate::backend::app_server::WorkspaceSession;
use crate::shared::workspaces_core::rename_worktree_core;
use crate::storage::{read_workspaces, write_workspaces};
use crate::types::{WorktreeInfo, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings};
use crate::types::{
AppSettings, WorktreeInfo, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings,
};
use tokio::runtime::Runtime;
use tokio::sync::Mutex;
use uuid::Uuid;

fn workspace(name: &str, sort_order: Option<u32>) -> WorkspaceInfo {
Expand Down Expand Up @@ -52,6 +60,11 @@ fn workspace_with_id_and_kind(
}
}

fn run_async<F: Future<Output = ()>>(future: F) {
let runtime = Runtime::new().expect("create runtime");
runtime.block_on(future);
}

#[test]
fn sanitize_worktree_name_rewrites_specials() {
assert_eq!(sanitize_worktree_name("feature/new-thing"), "feature-new-thing");
Expand Down Expand Up @@ -230,3 +243,139 @@ fn update_workspace_settings_persists_sort_and_group() {
Some("pnpm install"),
);
}

#[test]
fn rename_worktree_preserves_custom_name() {
run_async(async {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
let repo_path = temp_dir.join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo path");
let worktree_path = temp_dir.join("worktrees").join("parent").join("old");
std::fs::create_dir_all(&worktree_path).expect("create worktree path");

let parent = WorkspaceEntry {
id: "parent".to_string(),
name: "Parent".to_string(),
path: repo_path.to_string_lossy().to_string(),
codex_bin: None,
kind: WorkspaceKind::Main,
parent_id: None,
worktree: None,
settings: WorkspaceSettings::default(),
};
let worktree = WorkspaceEntry {
id: "wt-1".to_string(),
name: "Custom label".to_string(),
path: worktree_path.to_string_lossy().to_string(),
codex_bin: None,
kind: WorkspaceKind::Worktree,
parent_id: Some(parent.id.clone()),
worktree: Some(WorktreeInfo {
branch: "feature/old".to_string(),
}),
settings: WorkspaceSettings::default(),
};
let workspaces = Mutex::new(HashMap::from([
(parent.id.clone(), parent.clone()),
(worktree.id.clone(), worktree.clone()),
]));
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
let app_settings = Mutex::new(AppSettings::default());
let storage_path = temp_dir.join("workspaces.json");

let updated = rename_worktree_core(
worktree.id.clone(),
"feature/new".to_string(),
&temp_dir,
&workspaces,
&sessions,
&app_settings,
&storage_path,
|_| Ok(repo_path.clone()),
|_root, branch| {
let branch = branch.to_string();
async move { Ok(branch) }
},
|value| sanitize_worktree_name(value),
|_, _, current| Ok(current.to_path_buf()),
|_root, _args| async move { Ok(()) },
|_entry, _default_bin, _codex_args, _codex_home| async move {
Err("spawn not expected".to_string())
},
)
.await
.expect("rename worktree");

assert_eq!(updated.name, "Custom label");
assert_eq!(
updated.worktree.as_ref().map(|worktree| worktree.branch.as_str()),
Some("feature/new")
);
});
}

#[test]
fn rename_worktree_updates_name_when_unmodified() {
run_async(async {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
let repo_path = temp_dir.join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo path");
let worktree_path = temp_dir.join("worktrees").join("parent").join("old");
std::fs::create_dir_all(&worktree_path).expect("create worktree path");

let parent = WorkspaceEntry {
id: "parent".to_string(),
name: "Parent".to_string(),
path: repo_path.to_string_lossy().to_string(),
codex_bin: None,
kind: WorkspaceKind::Main,
parent_id: None,
worktree: None,
settings: WorkspaceSettings::default(),
};
let worktree = WorkspaceEntry {
id: "wt-2".to_string(),
name: "feature/old".to_string(),
path: worktree_path.to_string_lossy().to_string(),
codex_bin: None,
kind: WorkspaceKind::Worktree,
parent_id: Some(parent.id.clone()),
worktree: Some(WorktreeInfo {
branch: "feature/old".to_string(),
}),
settings: WorkspaceSettings::default(),
};
let workspaces = Mutex::new(HashMap::from([
(parent.id.clone(), parent.clone()),
(worktree.id.clone(), worktree.clone()),
]));
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
let app_settings = Mutex::new(AppSettings::default());
let storage_path = temp_dir.join("workspaces.json");

let updated = rename_worktree_core(
worktree.id.clone(),
"feature/new".to_string(),
&temp_dir,
&workspaces,
&sessions,
&app_settings,
&storage_path,
|_| Ok(repo_path.clone()),
|_root, branch| {
let branch = branch.to_string();
async move { Ok(branch) }
},
|value| sanitize_worktree_name(value),
|_, _, current| Ok(current.to_path_buf()),
|_root, _args| async move { Ok(()) },
|_entry, _default_bin, _codex_args, _codex_home| async move {
Err("spawn not expected".to_string())
},
)
.await
.expect("rename worktree");

assert_eq!(updated.name, "feature/new");
});
}
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,7 @@ function MainApp() {
openPrompt: openWorktreePrompt,
confirmPrompt: confirmWorktreePrompt,
cancelPrompt: cancelWorktreePrompt,
updateName: updateWorktreeName,
updateBranch: updateWorktreeBranch,
updateSetupScript: updateWorktreeSetupScript,
} = useWorktreePrompt({
Expand Down Expand Up @@ -1365,7 +1366,7 @@ function MainApp() {
? workspacesById.get(activeWorkspace?.parentId ?? "") ?? null
: null;
const worktreeLabel = isWorktreeWorkspace
? activeWorkspace?.worktree?.branch ?? activeWorkspace?.name ?? null
? (activeWorkspace?.name?.trim() || activeWorkspace?.worktree?.branch) ?? null
: null;
const activeRenamePrompt =
renameWorktreePrompt?.workspaceId === activeWorkspace?.id
Expand Down Expand Up @@ -2278,6 +2279,7 @@ function MainApp() {
onRenamePromptCancel={handleRenamePromptCancel}
onRenamePromptConfirm={handleRenamePromptConfirm}
worktreePrompt={worktreePrompt}
onWorktreePromptNameChange={updateWorktreeName}
onWorktreePromptChange={updateWorktreeBranch}
onWorktreeSetupScriptChange={updateWorktreeSetupScript}
onWorktreePromptCancel={cancelWorktreePrompt}
Expand Down
4 changes: 4 additions & 0 deletions src/features/app/components/AppModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type AppModalsProps = {
onRenamePromptCancel: () => void;
onRenamePromptConfirm: () => void;
worktreePrompt: WorktreePromptState;
onWorktreePromptNameChange: (value: string) => void;
onWorktreePromptChange: (value: string) => void;
onWorktreeSetupScriptChange: (value: string) => void;
onWorktreePromptCancel: () => void;
Expand Down Expand Up @@ -71,6 +72,7 @@ export const AppModals = memo(function AppModals({
onRenamePromptCancel,
onRenamePromptConfirm,
worktreePrompt,
onWorktreePromptNameChange,
onWorktreePromptChange,
onWorktreeSetupScriptChange,
onWorktreePromptCancel,
Expand Down Expand Up @@ -112,12 +114,14 @@ export const AppModals = memo(function AppModals({
<Suspense fallback={null}>
<WorktreePrompt
workspaceName={worktreePrompt.workspace.name}
name={worktreePrompt.name}
branch={worktreePrompt.branch}
setupScript={worktreePrompt.setupScript}
scriptError={worktreePrompt.scriptError}
error={worktreePrompt.error}
isBusy={worktreePrompt.isSubmitting}
isSavingScript={worktreePrompt.isSavingScript}
onNameChange={onWorktreePromptNameChange}
onChange={onWorktreePromptChange}
onSetupScriptChange={onWorktreeSetupScriptChange}
onCancel={onWorktreePromptCancel}
Expand Down
3 changes: 2 additions & 1 deletion src/features/app/components/WorktreeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function WorktreeCard({
}: WorktreeCardProps) {
const worktreeCollapsed = worktree.settings.sidebarCollapsed;
const worktreeBranch = worktree.worktree?.branch ?? "";
const worktreeLabel = worktree.name?.trim() || worktreeBranch;
const contentCollapsedClass = worktreeCollapsed ? " collapsed" : "";

return (
Expand Down Expand Up @@ -54,7 +55,7 @@ export function WorktreeCard({
}
}}
>
<div className="worktree-label">{worktreeBranch || worktree.name}</div>
<div className="worktree-label">{worktreeLabel}</div>
<div className="worktree-actions">
{isDeleting ? (
<div className="worktree-deleting" role="status" aria-live="polite">
Expand Down
28 changes: 27 additions & 1 deletion src/features/workspaces/components/WorktreePrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { useEffect, useRef } from "react";

type WorktreePromptProps = {
workspaceName: string;
name: string;
branch: string;
setupScript: string;
scriptError?: string | null;
error?: string | null;
onNameChange: (value: string) => void;
onChange: (value: string) => void;
onSetupScriptChange: (value: string) => void;
onCancel: () => void;
Expand All @@ -16,10 +18,12 @@ type WorktreePromptProps = {

export function WorktreePrompt({
workspaceName,
name,
branch,
setupScript,
scriptError = null,
error = null,
onNameChange,
onChange,
onSetupScriptChange,
onCancel,
Expand Down Expand Up @@ -49,12 +53,34 @@ export function WorktreePrompt({
<div className="worktree-modal-subtitle">
Create a worktree under "{workspaceName}".
</div>
<label className="worktree-modal-label" htmlFor="worktree-name">
Name
</label>
<input
id="worktree-name"
ref={inputRef}
className="worktree-modal-input"
value={name}
placeholder="(Optional)"
onChange={(event) => onNameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
if (!isBusy) {
onCancel();
}
}
if (event.key === "Enter" && !isBusy) {
event.preventDefault();
onConfirm();
}
}}
/>
<label className="worktree-modal-label" htmlFor="worktree-branch">
Branch name
</label>
<input
id="worktree-branch"
ref={inputRef}
className="worktree-modal-input"
value={branch}
onChange={(event) => onChange(event.target.value)}
Expand Down
Loading