diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 27d623bf..17961cea 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -208,12 +208,14 @@ impl DaemonState { &self, parent_id: String, branch: String, + name: Option, client_version: String, ) -> Result { let client_version = client_version.clone(); workspaces_core::add_worktree_core( parent_id, branch, + name, &self.data_dir, &self.workspaces, &self.sessions, @@ -986,8 +988,9 @@ async fn handle_rpc_request( "add_worktree" => { let parent_id = parse_string(¶ms, "parentId")?; let branch = parse_string(¶ms, "branch")?; + let name = parse_optional_string(¶ms, "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()) } diff --git a/src-tauri/src/shared/workspaces_core.rs b/src-tauri/src/shared/workspaces_core.rs index f6cc2fe9..2dcba7b9 100644 --- a/src-tauri/src/shared/workspaces_core.rs +++ b/src-tauri/src/shared/workspaces_core.rs @@ -247,6 +247,7 @@ pub(crate) async fn add_worktree_core< >( parent_id: String, branch: String, + name: Option, data_dir: &PathBuf, workspaces: &Mutex>, sessions: &Mutex>>, @@ -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; @@ -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, @@ -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) => { diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index f7e7f217..b2f0b278 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -284,6 +284,7 @@ pub(crate) async fn add_clone( pub(crate) async fn add_worktree( parent_id: String, branch: String, + name: Option, state: State<'_, AppState>, app: AppHandle, ) -> Result { @@ -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()); @@ -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, diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 2e7a6959..d1812e4b 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -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) -> WorkspaceInfo { @@ -52,6 +60,11 @@ fn workspace_with_id_and_kind( } } +fn run_async>(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"); @@ -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>> = 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>> = 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"); + }); +} diff --git a/src/App.tsx b/src/App.tsx index 8f3cb33b..0259fdf2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -896,6 +896,7 @@ function MainApp() { openPrompt: openWorktreePrompt, confirmPrompt: confirmWorktreePrompt, cancelPrompt: cancelWorktreePrompt, + updateName: updateWorktreeName, updateBranch: updateWorktreeBranch, updateSetupScript: updateWorktreeSetupScript, } = useWorktreePrompt({ @@ -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 @@ -2278,6 +2279,7 @@ function MainApp() { onRenamePromptCancel={handleRenamePromptCancel} onRenamePromptConfirm={handleRenamePromptConfirm} worktreePrompt={worktreePrompt} + onWorktreePromptNameChange={updateWorktreeName} onWorktreePromptChange={updateWorktreeBranch} onWorktreeSetupScriptChange={updateWorktreeSetupScript} onWorktreePromptCancel={cancelWorktreePrompt} diff --git a/src/features/app/components/AppModals.tsx b/src/features/app/components/AppModals.tsx index 750d8566..190edab1 100644 --- a/src/features/app/components/AppModals.tsx +++ b/src/features/app/components/AppModals.tsx @@ -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; @@ -71,6 +72,7 @@ export const AppModals = memo(function AppModals({ onRenamePromptCancel, onRenamePromptConfirm, worktreePrompt, + onWorktreePromptNameChange, onWorktreePromptChange, onWorktreeSetupScriptChange, onWorktreePromptCancel, @@ -112,12 +114,14 @@ export const AppModals = memo(function AppModals({ -
{worktreeBranch || worktree.name}
+
{worktreeLabel}
{isDeleting ? (
diff --git a/src/features/workspaces/components/WorktreePrompt.tsx b/src/features/workspaces/components/WorktreePrompt.tsx index d6ae8a30..3efbd93a 100644 --- a/src/features/workspaces/components/WorktreePrompt.tsx +++ b/src/features/workspaces/components/WorktreePrompt.tsx @@ -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; @@ -16,10 +18,12 @@ type WorktreePromptProps = { export function WorktreePrompt({ workspaceName, + name, branch, setupScript, scriptError = null, error = null, + onNameChange, onChange, onSetupScriptChange, onCancel, @@ -49,12 +53,34 @@ export function WorktreePrompt({
Create a worktree under "{workspaceName}".
+ + onNameChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + if (!isBusy) { + onCancel(); + } + } + if (event.key === "Enter" && !isBusy) { + event.preventDefault(); + onConfirm(); + } + }} + /> onChange(event.target.value)} diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index 19f1ce76..9fca3c57 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -275,21 +275,22 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { async function addWorktreeAgent( parent: WorkspaceInfo, branch: string, - options?: { activate?: boolean }, + options?: { activate?: boolean; displayName?: string | null }, ) { const trimmed = branch.trim(); if (!trimmed) { return null; } + const trimmedName = options?.displayName?.trim() || null; onDebug?.({ id: `${Date.now()}-client-add-worktree`, timestamp: Date.now(), source: "client", label: "worktree/add", - payload: { parentId: parent.id, branch: trimmed }, + payload: { parentId: parent.id, branch: trimmed, name: trimmedName }, }); try { - const workspace = await addWorktreeService(parent.id, trimmed); + const workspace = await addWorktreeService(parent.id, trimmed, trimmedName); setWorkspaces((prev) => [...prev, workspace]); if (options?.activate !== false) { setActiveWorkspaceId(workspace.id); diff --git a/src/features/workspaces/hooks/useWorktreePrompt.test.tsx b/src/features/workspaces/hooks/useWorktreePrompt.test.tsx new file mode 100644 index 00000000..0f94127b --- /dev/null +++ b/src/features/workspaces/hooks/useWorktreePrompt.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { useWorktreePrompt } from "./useWorktreePrompt"; + +const parentWorkspace: WorkspaceInfo = { + id: "ws-1", + name: "Parent", + path: "/tmp/ws-1", + connected: true, + kind: "main", + settings: { sidebarCollapsed: false }, +}; + +describe("useWorktreePrompt", () => { + it("derives branch from name until branch is manually edited", () => { + const addWorktreeAgent = vi.fn().mockResolvedValue(null); + const updateWorkspaceSettings = vi.fn().mockResolvedValue(parentWorkspace); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const onSelectWorkspace = vi.fn(); + + const { result } = renderHook(() => + useWorktreePrompt({ + addWorktreeAgent, + updateWorkspaceSettings, + connectWorkspace, + onSelectWorkspace, + }), + ); + + act(() => { + result.current.openPrompt(parentWorkspace); + }); + + act(() => { + result.current.updateName("My New Feature!"); + }); + + expect(result.current.worktreePrompt?.branch).toBe("codex/my-new-feature"); + + act(() => { + result.current.updateBranch("custom/branch-name"); + }); + + act(() => { + result.current.updateName("Another Idea"); + }); + + expect(result.current.worktreePrompt?.branch).toBe("custom/branch-name"); + expect(addWorktreeAgent).not.toHaveBeenCalled(); + }); + + it("does not override branch when name is cleared", () => { + const addWorktreeAgent = vi.fn().mockResolvedValue(null); + const updateWorkspaceSettings = vi.fn().mockResolvedValue(parentWorkspace); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const onSelectWorkspace = vi.fn(); + + const { result } = renderHook(() => + useWorktreePrompt({ + addWorktreeAgent, + updateWorkspaceSettings, + connectWorkspace, + onSelectWorkspace, + }), + ); + + act(() => { + result.current.openPrompt(parentWorkspace); + }); + + const originalBranch = result.current.worktreePrompt?.branch; + + act(() => { + result.current.updateName(" "); + }); + + expect(result.current.worktreePrompt?.branch).toBe(originalBranch); + expect(addWorktreeAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/hooks/useWorktreePrompt.ts b/src/features/workspaces/hooks/useWorktreePrompt.ts index 052baba5..02e74804 100644 --- a/src/features/workspaces/hooks/useWorktreePrompt.ts +++ b/src/features/workspaces/hooks/useWorktreePrompt.ts @@ -3,7 +3,9 @@ import type { WorkspaceInfo, WorkspaceSettings } from "../../../types"; type WorktreePromptState = { workspace: WorkspaceInfo; + name: string; branch: string; + branchWasEdited: boolean; setupScript: string; savedSetupScript: string | null; isSubmitting: boolean; @@ -16,6 +18,7 @@ type UseWorktreePromptOptions = { addWorktreeAgent: ( workspace: WorkspaceInfo, branch: string, + options?: { displayName?: string | null }, ) => Promise; updateWorkspaceSettings: ( id: string, @@ -33,6 +36,7 @@ type UseWorktreePromptResult = { openPrompt: (workspace: WorkspaceInfo) => void; confirmPrompt: () => Promise; cancelPrompt: () => void; + updateName: (value: string) => void; updateBranch: (value: string) => void; updateSetupScript: (value: string) => void; }; @@ -42,6 +46,21 @@ function normalizeSetupScript(value: string | null | undefined): string | null { return next.trim().length > 0 ? next : null; } +function toBranchFromName(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + const slug = trimmed + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/(^-|-$)/g, ""); + if (!slug) { + return null; + } + return `codex/${slug}`; +} + export function useWorktreePrompt({ addWorktreeAgent, updateWorkspaceSettings, @@ -60,7 +79,9 @@ export function useWorktreePrompt({ const savedSetupScript = normalizeSetupScript(workspace.settings.worktreeSetupScript); setWorktreePrompt({ workspace, + name: "", branch: defaultBranch, + branchWasEdited: false, setupScript: savedSetupScript ?? "", savedSetupScript, isSubmitting: false, @@ -70,8 +91,31 @@ export function useWorktreePrompt({ }); }, []); + const updateName = useCallback((value: string) => { + setWorktreePrompt((prev) => { + if (!prev) { + return prev; + } + if (prev.branchWasEdited) { + return { ...prev, name: value, error: null }; + } + const nextBranch = toBranchFromName(value); + if (!nextBranch) { + return { ...prev, name: value, error: null }; + } + return { + ...prev, + name: value, + branch: nextBranch, + error: null, + }; + }); + }, []); + const updateBranch = useCallback((value: string) => { - setWorktreePrompt((prev) => (prev ? { ...prev, branch: value, error: null } : prev)); + setWorktreePrompt((prev) => + prev ? { ...prev, branch: value, branchWasEdited: true, error: null } : prev, + ); }, []); const updateSetupScript = useCallback((value: string) => { @@ -144,7 +188,10 @@ export function useWorktreePrompt({ } try { - const worktreeWorkspace = await addWorktreeAgent(parentWorkspace, snapshot.branch); + const displayName = snapshot.name.trim(); + const worktreeWorkspace = await addWorktreeAgent(parentWorkspace, snapshot.branch, { + displayName: displayName.length > 0 ? displayName : null, + }); if (!worktreeWorkspace) { setWorktreePrompt(null); return; @@ -184,6 +231,7 @@ export function useWorktreePrompt({ openPrompt, confirmPrompt, cancelPrompt, + updateName, updateBranch, updateSetupScript, }; diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 58ce7ffb..9000fbdd 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -156,8 +156,9 @@ export async function addClone( export async function addWorktree( parentId: string, branch: string, + name: string | null, ): Promise { - return invoke("add_worktree", { parentId, branch }); + return invoke("add_worktree", { parentId, branch, name }); } export type WorktreeSetupStatus = {