diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 541f88acc..a91507fff 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -395,6 +395,233 @@ impl DaemonState { Ok(()) } + async fn rename_worktree( + &self, + id: String, + branch: String, + client_version: String, + ) -> Result { + let trimmed = branch.trim(); + if trimmed.is_empty() { + return Err("Branch name is required.".to_string()); + } + + let (entry, parent) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces.get(&id).cloned().ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry.parent_id.clone().ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + let old_branch = entry + .worktree + .as_ref() + .map(|worktree| worktree.branch.clone()) + .ok_or("worktree metadata missing")?; + if old_branch == trimmed { + return Err("Branch name is unchanged.".to_string()); + } + + let parent_root = PathBuf::from(&parent.path); + + let (final_branch, _was_suffixed) = + unique_branch_name(&parent_root, trimmed, None).await?; + if final_branch == old_branch { + return Err("Branch name is unchanged.".to_string()); + } + + run_git_command( + &parent_root, + &["branch", "-m", &old_branch, &final_branch], + ) + .await?; + + let worktree_root = self.data_dir.join("worktrees").join(&parent.id); + std::fs::create_dir_all(&worktree_root) + .map_err(|e| format!("Failed to create worktree directory: {e}"))?; + + let safe_name = sanitize_worktree_name(&final_branch); + let current_path = PathBuf::from(&entry.path); + let next_path = + unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_path)?; + let next_path_string = next_path.to_string_lossy().to_string(); + if next_path_string != entry.path { + if let Err(error) = run_git_command( + &parent_root, + &["worktree", "move", &entry.path, &next_path_string], + ) + .await + { + let _ = run_git_command( + &parent_root, + &["branch", "-m", &final_branch, &old_branch], + ) + .await; + return Err(error); + } + } + + let (entry_snapshot, list) = { + let mut workspaces = self.workspaces.lock().await; + let entry = match workspaces.get_mut(&id) { + Some(entry) => entry, + None => return Err("workspace not found".to_string()), + }; + entry.name = final_branch.clone(); + entry.path = next_path_string.clone(); + match entry.worktree.as_mut() { + Some(worktree) => { + worktree.branch = final_branch.clone(); + } + None => { + entry.worktree = Some(WorktreeInfo { + branch: final_branch.clone(), + }); + } + } + let snapshot = entry.clone(); + let list: Vec<_> = workspaces.values().cloned().collect(); + (snapshot, list) + }; + write_workspaces(&self.storage_path, &list)?; + + let was_connected = self.sessions.lock().await.contains_key(&entry_snapshot.id); + if was_connected { + self.kill_session(&entry_snapshot.id).await; + let default_bin = { + let settings = self.app_settings.lock().await; + settings.codex_bin.clone() + }; + let codex_home = + codex_home::resolve_workspace_codex_home(&entry_snapshot, Some(&parent.path)); + match spawn_workspace_session( + entry_snapshot.clone(), + default_bin, + client_version, + self.event_sink.clone(), + codex_home, + ) + .await + { + Ok(session) => { + self.sessions + .lock() + .await + .insert(entry_snapshot.id.clone(), session); + } + Err(error) => { + eprintln!( + "rename_worktree: respawn failed for {} after rename: {error}", + entry_snapshot.id + ); + } + } + } + + let connected = self.sessions.lock().await.contains_key(&entry_snapshot.id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + connected, + codex_bin: entry_snapshot.codex_bin, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) + } + + async fn rename_worktree_upstream( + &self, + id: String, + old_branch: String, + new_branch: String, + ) -> Result<(), String> { + let old_branch = old_branch.trim(); + let new_branch = new_branch.trim(); + if old_branch.is_empty() || new_branch.is_empty() { + return Err("Branch name is required.".to_string()); + } + if old_branch == new_branch { + return Err("Branch name is unchanged.".to_string()); + } + + let (_entry, parent) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces.get(&id).cloned().ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry.parent_id.clone().ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + let parent_root = PathBuf::from(&parent.path); + if !git_branch_exists(&parent_root, new_branch).await? { + return Err("Local branch not found.".to_string()); + } + + let remote_for_old = git_find_remote_for_branch(&parent_root, old_branch).await?; + let remote_name = match remote_for_old.as_ref() { + Some(remote) => remote.clone(), + None => { + if git_remote_exists(&parent_root, "origin").await? { + "origin".to_string() + } else { + return Err("No git remote configured for this worktree.".to_string()); + } + } + }; + + if git_remote_branch_exists_live(&parent_root, &remote_name, new_branch).await? { + return Err("Remote branch already exists.".to_string()); + } + + if remote_for_old.is_some() { + run_git_command( + &parent_root, + &[ + "push", + &remote_name, + &format!("{new_branch}:{new_branch}"), + ], + ) + .await?; + run_git_command( + &parent_root, + &["push", &remote_name, &format!(":{old_branch}")], + ) + .await?; + } else { + run_git_command(&parent_root, &["push", &remote_name, new_branch]).await?; + } + + run_git_command( + &parent_root, + &[ + "branch", + "--set-upstream-to", + &format!("{remote_name}/{new_branch}"), + new_branch, + ], + ) + .await?; + + Ok(()) + } + async fn update_workspace_settings( &self, id: String, @@ -855,6 +1082,50 @@ async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result Result { + let status = Command::new("git") + .args(["remote", "get-url", remote]) + .current_dir(repo_path) + .status() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + Ok(status.success()) +} + +async fn git_remote_branch_exists_live( + repo_path: &PathBuf, + remote: &str, + branch: &str, +) -> Result { + let output = Command::new("git") + .args([ + "ls-remote", + "--heads", + remote, + &format!("refs/heads/{branch}"), + ]) + .current_dir(repo_path) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if output.status.success() { + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + Err("Git command failed.".to_string()) + } else { + Err(detail.to_string()) + } + } +} + async fn git_remote_branch_exists(repo_path: &PathBuf, remote: &str, branch: &str) -> Result { let status = Command::new("git") .args([ @@ -869,6 +1140,37 @@ async fn git_remote_branch_exists(repo_path: &PathBuf, remote: &str, branch: &st Ok(status.success()) } +async fn unique_branch_name( + repo_path: &PathBuf, + desired: &str, + remote: Option<&str>, +) -> Result<(String, bool), String> { + let mut candidate = desired.to_string(); + if desired.is_empty() { + return Ok((candidate, false)); + } + if !git_branch_exists(repo_path, &candidate).await? + && match remote { + Some(remote) => !git_remote_branch_exists_live(repo_path, remote, &candidate).await?, + None => true, + } + { + return Ok((candidate, false)); + } + for index in 2..1000 { + candidate = format!("{desired}-{index}"); + let local_exists = git_branch_exists(repo_path, &candidate).await?; + let remote_exists = match remote { + Some(remote) => git_remote_branch_exists_live(repo_path, remote, &candidate).await?, + None => false, + }; + if !local_exists && !remote_exists { + return Ok((candidate, true)); + } + } + Err("Unable to find an available branch name.".to_string()) +} + async fn git_list_remotes(repo_path: &PathBuf) -> Result, String> { let output = run_git_command(repo_path, &["remote"]).await?; Ok(output @@ -879,6 +1181,28 @@ async fn git_list_remotes(repo_path: &PathBuf) -> Result, String> { .collect()) } +async fn git_find_remote_for_branch( + repo_path: &PathBuf, + branch: &str, +) -> Result, String> { + if git_remote_exists(repo_path, "origin").await? + && git_remote_branch_exists_live(repo_path, "origin", branch).await? + { + return Ok(Some("origin".to_string())); + } + + for remote in git_list_remotes(repo_path).await? { + if remote == "origin" { + continue; + } + if git_remote_branch_exists_live(repo_path, &remote, branch).await? { + return Ok(Some(remote)); + } + } + + Ok(None) +} + async fn git_find_remote_tracking_branch(repo_path: &PathBuf, branch: &str) -> Result, String> { if git_remote_branch_exists(repo_path, "origin", branch).await? { return Ok(Some(format!("origin/{branch}"))); @@ -932,6 +1256,30 @@ fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> Result Result { + let candidate = base_dir.join(name); + if candidate == *current_path { + return Ok(candidate); + } + if !candidate.exists() { + return Ok(candidate); + } + for index in 2..1000 { + let next = base_dir.join(format!("{name}-{index}")); + if next == *current_path || !next.exists() { + return Ok(next); + } + } + Err(format!( + "Failed to find an available worktree path under {}.", + base_dir.display() + )) +} + fn default_data_dir() -> PathBuf { if let Ok(xdg) = env::var("XDG_DATA_HOME") { let trimmed = xdg.trim(); @@ -1155,6 +1503,21 @@ async fn handle_rpc_request( state.remove_worktree(id).await?; Ok(json!({ "ok": true })) } + "rename_worktree" => { + let id = parse_string(¶ms, "id")?; + let branch = parse_string(¶ms, "branch")?; + let workspace = state.rename_worktree(id, branch, client_version).await?; + serde_json::to_value(workspace).map_err(|err| err.to_string()) + } + "rename_worktree_upstream" => { + let id = parse_string(¶ms, "id")?; + let old_branch = parse_string(¶ms, "oldBranch")?; + let new_branch = parse_string(¶ms, "newBranch")?; + state + .rename_worktree_upstream(id, old_branch, new_branch) + .await?; + Ok(json!({ "ok": true })) + } "update_workspace_settings" => { let id = parse_string(¶ms, "id")?; let settings_value = match params { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3306bf7bd..2c36890b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -286,6 +286,8 @@ pub fn run() { workspaces::add_worktree, workspaces::remove_workspace, workspaces::remove_worktree, + workspaces::rename_worktree, + workspaces::rename_worktree_upstream, workspaces::apply_worktree_changes, workspaces::update_workspace_settings, workspaces::update_workspace_codex_bin, diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 89dcfe085..f5fbd873e 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -215,6 +215,113 @@ async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result Result { + let status = Command::new("git") + .args(["remote", "get-url", remote]) + .current_dir(repo_path) + .status() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + Ok(status.success()) +} + +async fn git_remote_branch_exists( + repo_path: &PathBuf, + remote: &str, + branch: &str, +) -> Result { + let output = Command::new("git") + .args([ + "ls-remote", + "--heads", + remote, + &format!("refs/heads/{branch}"), + ]) + .current_dir(repo_path) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if output.status.success() { + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + Err("Git command failed.".to_string()) + } else { + Err(detail.to_string()) + } + } +} + +async fn git_list_remotes(repo_path: &PathBuf) -> Result, String> { + let output = run_git_command(repo_path, &["remote"]).await?; + Ok(output + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect()) +} + +async fn git_find_remote_for_branch( + repo_path: &PathBuf, + branch: &str, +) -> Result, String> { + if git_remote_exists(repo_path, "origin").await? + && git_remote_branch_exists(repo_path, "origin", branch).await? + { + return Ok(Some("origin".to_string())); + } + + for remote in git_list_remotes(repo_path).await? { + if remote == "origin" { + continue; + } + if git_remote_branch_exists(repo_path, &remote, branch).await? { + return Ok(Some(remote)); + } + } + + Ok(None) +} + +async fn unique_branch_name( + repo_path: &PathBuf, + desired: &str, + remote: Option<&str>, +) -> Result<(String, bool), String> { + let mut candidate = desired.to_string(); + if desired.is_empty() { + return Ok((candidate, false)); + } + if !git_branch_exists(repo_path, &candidate).await? + && match remote { + Some(remote) => !git_remote_branch_exists(repo_path, remote, &candidate).await?, + None => true, + } + { + return Ok((candidate, false)); + } + for index in 2..1000 { + candidate = format!("{desired}-{index}"); + let local_exists = git_branch_exists(repo_path, &candidate).await?; + let remote_exists = match remote { + Some(remote) => git_remote_branch_exists(repo_path, remote, &candidate).await?, + None => false, + }; + if !local_exists && !remote_exists { + return Ok((candidate, true)); + } + } + Err("Unable to find an available branch name.".to_string()) +} + async fn git_get_origin_url(repo_path: &PathBuf) -> Option { match run_git_command(repo_path, &["config", "--get", "remote.origin.url"]).await { Ok(url) if !url.trim().is_empty() => Some(url), @@ -237,6 +344,30 @@ fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { candidate } +fn unique_worktree_path_for_rename( + base_dir: &PathBuf, + name: &str, + current_path: &PathBuf, +) -> Result { + let candidate = base_dir.join(name); + if candidate == *current_path { + return Ok(candidate); + } + if !candidate.exists() { + return Ok(candidate); + } + for index in 2..1000 { + let next = base_dir.join(format!("{name}-{index}")); + if next == *current_path || !next.exists() { + return Ok(next); + } + } + Err(format!( + "Failed to find an available worktree path under {}.", + base_dir.display() + )) +} + fn build_clone_destination_path(copies_folder: &PathBuf, copy_name: &str) -> PathBuf { let safe_name = sanitize_clone_dir_name(copy_name); unique_worktree_path(copies_folder, &safe_name) @@ -684,6 +815,269 @@ pub(crate) async fn remove_worktree( Ok(()) } +#[tauri::command] +pub(crate) async fn rename_worktree( + id: String, + branch: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + let response = remote_backend::call_remote( + &*state, + app, + "rename_worktree", + json!({ "id": id, "branch": branch }), + ) + .await?; + return serde_json::from_value(response).map_err(|err| err.to_string()); + } + + let trimmed = branch.trim(); + if trimmed.is_empty() { + return Err("Branch name is required.".to_string()); + } + + let (entry, parent) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + let old_branch = entry + .worktree + .as_ref() + .map(|worktree| worktree.branch.clone()) + .ok_or("worktree metadata missing")?; + if old_branch == trimmed { + return Err("Branch name is unchanged.".to_string()); + } + + let parent_root = resolve_git_root(&parent)?; + let (final_branch, _was_suffixed) = + unique_branch_name(&parent_root, trimmed, None).await?; + if final_branch == old_branch { + return Err("Branch name is unchanged.".to_string()); + } + + run_git_command( + &parent_root, + &["branch", "-m", &old_branch, &final_branch], + ) + .await?; + + let worktree_root = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))? + .join("worktrees") + .join(&parent.id); + std::fs::create_dir_all(&worktree_root) + .map_err(|e| format!("Failed to create worktree directory: {e}"))?; + + let safe_name = sanitize_worktree_name(&final_branch); + let current_path = PathBuf::from(&entry.path); + let next_path = + unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_path)?; + let next_path_string = next_path.to_string_lossy().to_string(); + if next_path_string != entry.path { + if let Err(error) = run_git_command( + &parent_root, + &["worktree", "move", &entry.path, &next_path_string], + ) + .await + { + let _ = run_git_command( + &parent_root, + &["branch", "-m", &final_branch, &old_branch], + ) + .await; + return Err(error); + } + } + + let (entry_snapshot, list) = { + let mut workspaces = state.workspaces.lock().await; + let entry = match workspaces.get_mut(&id) { + Some(entry) => entry, + None => return Err("workspace not found".to_string()), + }; + entry.name = final_branch.clone(); + entry.path = next_path_string.clone(); + match entry.worktree.as_mut() { + Some(worktree) => { + worktree.branch = final_branch.clone(); + } + None => { + entry.worktree = Some(WorktreeInfo { + branch: final_branch.clone(), + }); + } + } + let snapshot = entry.clone(); + let list: Vec<_> = workspaces.values().cloned().collect(); + (snapshot, list) + }; + write_workspaces(&state.storage_path, &list)?; + + let was_connected = state.sessions.lock().await.contains_key(&entry_snapshot.id); + if was_connected { + if let Some(session) = state.sessions.lock().await.remove(&entry_snapshot.id) { + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let codex_home = resolve_workspace_codex_home(&entry_snapshot, Some(&parent.path)); + match spawn_workspace_session(entry_snapshot.clone(), default_bin, app, codex_home).await { + Ok(session) => { + state + .sessions + .lock() + .await + .insert(entry_snapshot.id.clone(), session); + } + Err(error) => { + eprintln!( + "rename_worktree: respawn failed for {} after rename: {error}", + entry_snapshot.id + ); + } + } + } + + let connected = state.sessions.lock().await.contains_key(&entry_snapshot.id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} + +#[tauri::command] +pub(crate) async fn rename_worktree_upstream( + id: String, + old_branch: String, + new_branch: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + if remote_backend::is_remote_mode(&*state).await { + remote_backend::call_remote( + &*state, + app, + "rename_worktree_upstream", + json!({ "id": id, "oldBranch": old_branch, "newBranch": new_branch }), + ) + .await?; + return Ok(()); + } + + let old_branch = old_branch.trim(); + let new_branch = new_branch.trim(); + if old_branch.is_empty() || new_branch.is_empty() { + return Err("Branch name is required.".to_string()); + } + if old_branch == new_branch { + return Err("Branch name is unchanged.".to_string()); + } + + let (_entry, parent) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + let parent_root = resolve_git_root(&parent)?; + if !git_branch_exists(&parent_root, new_branch).await? { + return Err("Local branch not found.".to_string()); + } + + let remote_for_old = git_find_remote_for_branch(&parent_root, old_branch).await?; + let remote_name = match remote_for_old.as_ref() { + Some(remote) => remote.clone(), + None => { + if git_remote_exists(&parent_root, "origin").await? { + "origin".to_string() + } else { + return Err("No git remote configured for this worktree.".to_string()); + } + } + }; + + if git_remote_branch_exists(&parent_root, &remote_name, new_branch).await? { + return Err("Remote branch already exists.".to_string()); + } + + if remote_for_old.is_some() { + run_git_command( + &parent_root, + &[ + "push", + &remote_name, + &format!("{new_branch}:{new_branch}"), + ], + ) + .await?; + run_git_command( + &parent_root, + &["push", &remote_name, &format!(":{old_branch}")], + ) + .await?; + } else { + run_git_command(&parent_root, &["push", &remote_name, new_branch]).await?; + } + + run_git_command( + &parent_root, + &[ + "branch", + "--set-upstream-to", + &format!("{remote_name}/{new_branch}"), + new_branch, + ], + ) + .await?; + + Ok(()) +} + #[tauri::command] pub(crate) async fn apply_worktree_changes( workspace_id: String, diff --git a/src/App.tsx b/src/App.tsx index c9f315d6d..1d697a57d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useGitBranches } from "./features/git/hooks/useGitBranches"; import { useDebugLog } from "./features/debug/hooks/useDebugLog"; import { useWorkspaceRefreshOnFocus } from "./features/workspaces/hooks/useWorkspaceRefreshOnFocus"; import { useWorkspaceRestore } from "./features/workspaces/hooks/useWorkspaceRestore"; +import { useRenameWorktreePrompt } from "./features/workspaces/hooks/useRenameWorktreePrompt"; import { useResizablePanels } from "./features/layout/hooks/useResizablePanels"; import { useLayoutMode } from "./features/layout/hooks/useLayoutMode"; import { useSidebarToggles } from "./features/layout/hooks/useSidebarToggles"; @@ -354,6 +355,8 @@ function MainApp() { assignWorkspaceGroup, removeWorkspace, removeWorktree, + renameWorktree, + renameWorktreeUpstream, hasLoaded, refreshWorkspaces } = useWorkspaces({ @@ -720,6 +723,8 @@ function MainApp() { startThreadForWorkspace, listThreadsForWorkspace, loadOlderThreadsForWorkspace, + resetWorkspaceThreads, + refreshThread, sendUserMessage, sendUserMessageToThread, startReview, @@ -765,6 +770,29 @@ function MainApp() { renameThread, }); + const { + renamePrompt: renameWorktreePrompt, + notice: renameWorktreeNotice, + upstreamPrompt: renameWorktreeUpstreamPrompt, + confirmUpstream: confirmRenameWorktreeUpstream, + openRenamePrompt: openRenameWorktreePrompt, + handleRenameChange: handleRenameWorktreeChange, + handleRenameCancel: handleRenameWorktreeCancel, + handleRenameConfirm: handleRenameWorktreeConfirm, + } = useRenameWorktreePrompt({ + workspaces, + activeWorkspaceId, + renameWorktree, + renameWorktreeUpstream, + onRenameSuccess: (workspace) => { + resetWorkspaceThreads(workspace.id); + void listThreadsForWorkspace(workspace); + if (activeThreadId && activeWorkspaceId === workspace.id) { + void refreshThread(workspace.id, activeThreadId); + } + }, + }); + const handleRenameThread = useCallback( (workspaceId: string, threadId: string) => { openRenamePrompt(workspaceId, threadId); @@ -772,6 +800,12 @@ function MainApp() { [openRenamePrompt], ); + const handleOpenRenameWorktree = useCallback(() => { + if (activeWorkspace) { + openRenameWorktreePrompt(activeWorkspace.id); + } + }, [activeWorkspace, openRenameWorktreePrompt]); + const { activeImages, attachImages, @@ -1287,6 +1321,37 @@ function MainApp() { const worktreeLabel = isWorktreeWorkspace ? activeWorkspace?.worktree?.branch ?? activeWorkspace?.name ?? null : null; + const activeRenamePrompt = + renameWorktreePrompt?.workspaceId === activeWorkspace?.id + ? renameWorktreePrompt + : null; + const worktreeRename = + isWorktreeWorkspace && activeWorkspace + ? { + name: activeRenamePrompt?.name ?? worktreeLabel ?? "", + error: activeRenamePrompt?.error ?? null, + notice: renameWorktreeNotice, + isSubmitting: activeRenamePrompt?.isSubmitting ?? false, + isDirty: activeRenamePrompt + ? activeRenamePrompt.name.trim() !== + activeRenamePrompt.originalName.trim() + : false, + upstream: + renameWorktreeUpstreamPrompt?.workspaceId === activeWorkspace.id + ? { + oldBranch: renameWorktreeUpstreamPrompt.oldBranch, + newBranch: renameWorktreeUpstreamPrompt.newBranch, + error: renameWorktreeUpstreamPrompt.error, + isSubmitting: renameWorktreeUpstreamPrompt.isSubmitting, + onConfirm: confirmRenameWorktreeUpstream, + } + : null, + onFocus: handleOpenRenameWorktree, + onChange: handleRenameWorktreeChange, + onCancel: handleRenameWorktreeCancel, + onCommit: handleRenameWorktreeConfirm, + } + : null; const baseWorkspaceRef = useRef(activeParentWorkspace ?? activeWorkspace); useEffect(() => { @@ -1762,6 +1827,7 @@ function MainApp() { activeWorkspace, activeParentWorkspace, worktreeLabel, + worktreeRename: worktreeRename ?? undefined, isWorktreeWorkspace, branchName: gitStatus.branchName || "unknown", branches, diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index f9f377a61..22531a709 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -30,6 +30,24 @@ type MainHeaderProps = { isTerminalOpen: boolean; showTerminalButton?: boolean; extraActionsNode?: ReactNode; + worktreeRename?: { + name: string; + error: string | null; + notice: string | null; + isSubmitting: boolean; + isDirty: boolean; + upstream?: { + oldBranch: string; + newBranch: string; + error: string | null; + isSubmitting: boolean; + onConfirm: () => void; + } | null; + onFocus: () => void; + onChange: (value: string) => void; + onCancel: () => void; + onCommit: () => void; + }; }; type OpenTarget = { @@ -56,6 +74,7 @@ export function MainHeader({ isTerminalOpen, showTerminalButton = true, extraActionsNode, + worktreeRename, }: MainHeaderProps) { const [menuOpen, setMenuOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); @@ -67,7 +86,10 @@ export function MainHeader({ const menuRef = useRef(null); const infoRef = useRef(null); const openMenuRef = useRef(null); + const renameInputRef = useRef(null); + const renameConfirmRef = useRef(null); const [openMenuOpen, setOpenMenuOpen] = useState(false); + const renameOnCancel = worktreeRename?.onCancel; const [openAppId, setOpenAppId] = useState(() => ( getStoredOpenAppId() )); @@ -144,6 +166,12 @@ export function MainHeader({ }; }, [infoOpen, menuOpen, openMenuOpen]); + useEffect(() => { + if (!infoOpen && renameOnCancel) { + renameOnCancel(); + } + }, [infoOpen, renameOnCancel]); + useEffect(() => { return () => { if (copyTimeoutRef.current) { @@ -206,6 +234,92 @@ export function MainHeader({ {infoOpen && (
+ {worktreeRename && ( +
+ Name +
+ { + worktreeRename.onFocus(); + renameInputRef.current?.select(); + }} + onChange={(event) => worktreeRename.onChange(event.target.value)} + onBlur={(event) => { + const nextTarget = event.relatedTarget as Node | null; + if ( + renameConfirmRef.current && + nextTarget && + renameConfirmRef.current.contains(nextTarget) + ) { + return; + } + if (!worktreeRename.isSubmitting && worktreeRename.isDirty) { + worktreeRename.onCommit(); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + if (!worktreeRename.isSubmitting) { + worktreeRename.onCancel(); + } + } + if (event.key === "Enter" && !worktreeRename.isSubmitting) { + event.preventDefault(); + worktreeRename.onCommit(); + } + }} + data-tauri-drag-region="false" + disabled={worktreeRename.isSubmitting} + /> + +
+ {worktreeRename.error && ( +
{worktreeRename.error}
+ )} + {worktreeRename.notice && ( + + {worktreeRename.notice} + + )} + {worktreeRename.upstream && ( +
+ + Do you want to update the upstream branch to{" "} + {worktreeRename.upstream.newBranch}? + + + {worktreeRename.upstream.error && ( +
+ {worktreeRename.upstream.error} +
+ )} +
+ )} +
+ )}
Worktree
diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 1599c79fd..461f27177 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -60,6 +60,25 @@ type GitDiffViewerItem = { diff: string; }; +type WorktreeRenameState = { + name: string; + error: string | null; + notice: string | null; + isSubmitting: boolean; + isDirty: boolean; + upstream?: { + oldBranch: string; + newBranch: string; + error: string | null; + isSubmitting: boolean; + onConfirm: () => void; + } | null; + onFocus: () => void; + onChange: (value: string) => void; + onCancel: () => void; + onCommit: () => void; +}; + type LayoutNodesOptions = { workspaces: WorkspaceInfo[]; groupedWorkspaces: Array<{ @@ -137,6 +156,7 @@ type LayoutNodesOptions = { activeWorkspace: WorkspaceInfo | null; activeParentWorkspace: WorkspaceInfo | null; worktreeLabel: string | null; + worktreeRename?: WorktreeRenameState; isWorktreeWorkspace: boolean; branchName: string; branches: BranchInfo[]; @@ -503,6 +523,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { workspace={options.activeWorkspace} parentName={options.activeParentWorkspace?.name ?? null} worktreeLabel={options.worktreeLabel} + worktreeRename={options.worktreeRename} disableBranchMenu={options.isWorktreeWorkspace} parentPath={options.activeParentWorkspace?.path ?? null} worktreePath={options.isWorktreeWorkspace ? options.activeWorkspace.path : null} diff --git a/src/features/threads/hooks/useThreads.ts b/src/features/threads/hooks/useThreads.ts index 74b822d3f..3aec75c62 100644 --- a/src/features/threads/hooks/useThreads.ts +++ b/src/features/threads/hooks/useThreads.ts @@ -439,6 +439,7 @@ export function useThreads({ }: UseThreadsOptions) { const [state, dispatch] = useReducer(threadReducer, initialState); const loadedThreads = useRef>({}); + const replaceOnResumeRef = useRef>({}); const threadActivityRef = useRef(loadThreadActivity()); const pinnedThreadsRef = useRef(loadPinnedThreads()); const [pinnedThreadsVersion, setPinnedThreadsVersion] = useState(0); @@ -1065,7 +1066,12 @@ export function useThreads({ }, [activeWorkspaceId, startThreadForWorkspace]); const resumeThreadForWorkspace = useCallback( - async (workspaceId: string, threadId: string, force = false) => { + async ( + workspaceId: string, + threadId: string, + force = false, + replaceLocal = false, + ) => { if (!threadId) { return null; } @@ -1101,8 +1107,17 @@ export function useThreads({ applyCollabThreadLinksFromThread(threadId, thread); const items = buildItemsFromThread(thread); const localItems = state.itemsByThread[threadId] ?? []; + const shouldReplace = + replaceLocal || replaceOnResumeRef.current[threadId] === true; + if (shouldReplace) { + replaceOnResumeRef.current[threadId] = false; + } const mergedItems = - items.length > 0 ? mergeThreadItems(items, localItems) : localItems; + items.length > 0 + ? shouldReplace + ? items + : mergeThreadItems(items, localItems) + : localItems; if (mergedItems.length > 0) { dispatch({ type: "setThreadItems", threadId, items: mergedItems }); } @@ -1155,6 +1170,33 @@ export function useThreads({ [applyCollabThreadLinksFromThread, getCustomName, onDebug, state.itemsByThread], ); + const refreshThread = useCallback( + async (workspaceId: string, threadId: string) => { + if (!threadId) { + return null; + } + replaceOnResumeRef.current[threadId] = true; + return resumeThreadForWorkspace(workspaceId, threadId, true, true); + }, + [resumeThreadForWorkspace], + ); + + const resetWorkspaceThreads = useCallback( + (workspaceId: string) => { + const threadIds = new Set(); + const list = state.threadsByWorkspace[workspaceId] ?? []; + list.forEach((thread) => threadIds.add(thread.id)); + const activeThread = state.activeThreadIdByWorkspace[workspaceId]; + if (activeThread) { + threadIds.add(activeThread); + } + threadIds.forEach((threadId) => { + loadedThreads.current[threadId] = false; + }); + }, + [state.activeThreadIdByWorkspace, state.threadsByWorkspace], + ); + const listThreadsForWorkspace = useCallback( async (workspace: WorkspaceInfo) => { const workspacePath = normalizeRootPath(workspace.path); @@ -1901,6 +1943,8 @@ export function useThreads({ startThread, startThreadForWorkspace, listThreadsForWorkspace, + refreshThread, + resetWorkspaceThreads, loadOlderThreadsForWorkspace, sendUserMessage, sendUserMessageToThread, diff --git a/src/features/workspaces/hooks/useRenameWorktreePrompt.test.tsx b/src/features/workspaces/hooks/useRenameWorktreePrompt.test.tsx new file mode 100644 index 000000000..d1d5630ac --- /dev/null +++ b/src/features/workspaces/hooks/useRenameWorktreePrompt.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { useRenameWorktreePrompt } from "./useRenameWorktreePrompt"; + +const worktree: WorkspaceInfo = { + id: "wt-1", + name: "feature/old", + path: "/tmp/wt-1", + connected: true, + kind: "worktree", + parentId: "parent-1", + worktree: { branch: "feature/old" }, + settings: { sidebarCollapsed: false }, +}; + +describe("useRenameWorktreePrompt", () => { + it("opens prompt and shows upstream confirmation after rename", async () => { + const renameWorktree = vi.fn().mockResolvedValue({ + ...worktree, + name: "feature/new", + worktree: { branch: "feature/new" }, + }); + const renameWorktreeUpstream = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRenameWorktreePrompt({ + workspaces: [worktree], + activeWorkspaceId: worktree.id, + renameWorktree, + renameWorktreeUpstream, + }), + ); + + act(() => { + result.current.openRenamePrompt(worktree.id); + result.current.handleRenameChange("feature/new"); + }); + + await act(async () => { + await result.current.handleRenameConfirm(); + }); + + expect(renameWorktree).toHaveBeenCalledWith(worktree.id, "feature/new"); + expect(result.current.upstreamPrompt).toEqual( + expect.objectContaining({ + workspaceId: worktree.id, + oldBranch: "feature/old", + newBranch: "feature/new", + }), + ); + + await act(async () => { + await result.current.confirmUpstream(); + }); + + expect(renameWorktreeUpstream).toHaveBeenCalledWith( + worktree.id, + "feature/old", + "feature/new", + ); + expect(result.current.upstreamPrompt).toBeNull(); + expect(result.current.notice).toBe("Upstream branch updated."); + }); + + it("surfaces rename errors", async () => { + const renameWorktree = vi + .fn() + .mockRejectedValue(new Error("rename failed")); + const renameWorktreeUpstream = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useRenameWorktreePrompt({ + workspaces: [worktree], + activeWorkspaceId: worktree.id, + renameWorktree, + renameWorktreeUpstream, + }), + ); + + act(() => { + result.current.openRenamePrompt(worktree.id); + result.current.handleRenameChange("feature/new"); + }); + + await act(async () => { + await result.current.handleRenameConfirm(); + }); + + expect(result.current.renamePrompt?.error).toBe("rename failed"); + }); +}); diff --git a/src/features/workspaces/hooks/useRenameWorktreePrompt.ts b/src/features/workspaces/hooks/useRenameWorktreePrompt.ts new file mode 100644 index 000000000..d8dae0072 --- /dev/null +++ b/src/features/workspaces/hooks/useRenameWorktreePrompt.ts @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { WorkspaceInfo } from "../../../types"; + +type RenamePromptState = { + workspaceId: string; + name: string; + originalName: string; + error: string | null; + isSubmitting: boolean; +}; + +type UpstreamPromptState = { + workspaceId: string; + oldBranch: string; + newBranch: string; + isSubmitting: boolean; + error: string | null; +}; + +type UseRenameWorktreePromptOptions = { + workspaces: WorkspaceInfo[]; + activeWorkspaceId: string | null; + renameWorktree: (workspaceId: string, branch: string) => Promise; + renameWorktreeUpstream: ( + workspaceId: string, + oldBranch: string, + newBranch: string, + ) => Promise; + onRenameSuccess?: (workspace: WorkspaceInfo) => void; +}; + +export function useRenameWorktreePrompt({ + workspaces, + activeWorkspaceId, + renameWorktree, + renameWorktreeUpstream, + onRenameSuccess, +}: UseRenameWorktreePromptOptions) { + const [renamePrompt, setRenamePrompt] = useState( + null, + ); + const [upstreamPrompt, setUpstreamPrompt] = + useState(null); + const [notice, setNotice] = useState(null); + const noticeTimerRef = useRef(null); + + const setNoticeMessage = useCallback((message: string) => { + setNotice(message); + if (noticeTimerRef.current) { + window.clearTimeout(noticeTimerRef.current); + } + noticeTimerRef.current = window.setTimeout(() => { + setNotice(null); + noticeTimerRef.current = null; + }, 2400); + }, []); + + useEffect(() => { + return () => { + if (noticeTimerRef.current) { + window.clearTimeout(noticeTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!renamePrompt) { + return; + } + const workspace = workspaces.find((entry) => entry.id === renamePrompt.workspaceId); + if (!workspace || (workspace.kind ?? "main") !== "worktree") { + setRenamePrompt(null); + return; + } + if (activeWorkspaceId && workspace.id !== activeWorkspaceId) { + setRenamePrompt(null); + } + }, [activeWorkspaceId, renamePrompt, workspaces]); + + useEffect(() => { + if (!upstreamPrompt) { + return; + } + if ( + activeWorkspaceId && + upstreamPrompt.workspaceId !== activeWorkspaceId + ) { + setUpstreamPrompt(null); + } + }, [activeWorkspaceId, upstreamPrompt]); + + const openRenamePrompt = useCallback( + (workspaceId: string) => { + const workspace = workspaces.find((entry) => entry.id === workspaceId); + if (!workspace || (workspace.kind ?? "main") !== "worktree") { + return; + } + const currentName = workspace.worktree?.branch ?? workspace.name; + setNotice(null); + setUpstreamPrompt(null); + setRenamePrompt({ + workspaceId, + name: currentName, + originalName: currentName, + error: null, + isSubmitting: false, + }); + }, + [workspaces], + ); + + const handleRenameChange = useCallback((value: string) => { + setRenamePrompt((prev) => + prev + ? { + ...prev, + name: value, + error: null, + } + : prev, + ); + }, []); + + const handleRenameCancel = useCallback(() => { + setRenamePrompt(null); + }, []); + + const handleRenameConfirm = useCallback(async () => { + if (!renamePrompt || renamePrompt.isSubmitting) { + return; + } + const target = renamePrompt; + setRenamePrompt({ ...target, error: null, isSubmitting: true }); + const trimmed = target.name.trim(); + if (!trimmed) { + setRenamePrompt((prev) => + prev + ? { + ...prev, + error: "Branch name is required.", + isSubmitting: false, + } + : prev, + ); + return; + } + if (trimmed === target.originalName) { + setRenamePrompt(null); + return; + } + try { + const updated = await renameWorktree(target.workspaceId, trimmed); + const actualName = updated.worktree?.branch ?? updated.name; + onRenameSuccess?.(updated); + if (actualName !== target.originalName) { + setUpstreamPrompt({ + workspaceId: target.workspaceId, + oldBranch: target.originalName, + newBranch: actualName, + isSubmitting: false, + error: null, + }); + } + if (actualName !== trimmed) { + setNoticeMessage(`Branch already exists. Renamed to "${actualName}".`); + } else { + setNoticeMessage("Worktree renamed."); + } + setRenamePrompt(null); + } catch (error) { + setRenamePrompt((prev) => + prev + ? { + ...prev, + error: error instanceof Error ? error.message : String(error), + isSubmitting: false, + } + : prev, + ); + } + }, [onRenameSuccess, renamePrompt, renameWorktree, setNoticeMessage]); + + const confirmUpstream = useCallback(async () => { + if (!upstreamPrompt || upstreamPrompt.isSubmitting) { + return; + } + setUpstreamPrompt((prev) => + prev ? { ...prev, isSubmitting: true, error: null } : prev, + ); + try { + await renameWorktreeUpstream( + upstreamPrompt.workspaceId, + upstreamPrompt.oldBranch, + upstreamPrompt.newBranch, + ); + setUpstreamPrompt(null); + setNoticeMessage("Upstream branch updated."); + } catch (error) { + setUpstreamPrompt((prev) => + prev + ? { + ...prev, + isSubmitting: false, + error: error instanceof Error ? error.message : String(error), + } + : prev, + ); + } + }, [renameWorktreeUpstream, setNoticeMessage, upstreamPrompt]); + + return { + renamePrompt, + notice, + upstreamPrompt, + confirmUpstream, + openRenamePrompt, + handleRenameChange, + handleRenameCancel, + handleRenameConfirm, + }; +} diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx new file mode 100644 index 000000000..fb785cf43 --- /dev/null +++ b/src/features/workspaces/hooks/useWorkspaces.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { + listWorkspaces, + renameWorktree, + renameWorktreeUpstream, +} from "../../../services/tauri"; +import { useWorkspaces } from "./useWorkspaces"; + +vi.mock("../../../services/tauri", () => ({ + listWorkspaces: vi.fn(), + renameWorktree: vi.fn(), + renameWorktreeUpstream: vi.fn(), + addClone: vi.fn(), + addWorkspace: vi.fn(), + addWorktree: vi.fn(), + connectWorkspace: vi.fn(), + pickWorkspacePath: vi.fn(), + removeWorkspace: vi.fn(), + removeWorktree: vi.fn(), + updateWorkspaceCodexBin: vi.fn(), + updateWorkspaceSettings: vi.fn(), +})); + +const worktree: WorkspaceInfo = { + id: "wt-1", + name: "feature/old", + path: "/tmp/wt-1", + connected: true, + kind: "worktree", + parentId: "parent-1", + worktree: { branch: "feature/old" }, + settings: { sidebarCollapsed: false }, +}; + +describe("useWorkspaces.renameWorktree", () => { + it("optimistically updates and reconciles on success", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const renameWorktreeMock = vi.mocked(renameWorktree); + listWorkspacesMock.mockResolvedValue([worktree]); + + let resolveRename: (value: WorkspaceInfo) => void = () => {}; + const renamePromise = new Promise((resolve) => { + resolveRename = resolve; + }); + renameWorktreeMock.mockReturnValue(renamePromise); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + let renameCall: Promise; + act(() => { + renameCall = result.current.renameWorktree("wt-1", "feature/new"); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.workspaces[0].name).toBe("feature/new"); + expect(result.current.workspaces[0].worktree?.branch).toBe("feature/new"); + + resolveRename({ + ...worktree, + name: "feature/new", + path: "/tmp/wt-1-renamed", + worktree: { branch: "feature/new" }, + }); + + await act(async () => { + await renameCall; + }); + + expect(result.current.workspaces[0].path).toBe("/tmp/wt-1-renamed"); + }); + + it("rolls back optimistic update on failure", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const renameWorktreeMock = vi.mocked(renameWorktree); + listWorkspacesMock.mockResolvedValue([worktree]); + let rejectRename: (error: Error) => void = () => {}; + const renamePromise = new Promise((_, reject) => { + rejectRename = reject; + }); + renameWorktreeMock.mockReturnValue(renamePromise); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + let renameCall: Promise; + act(() => { + renameCall = result.current.renameWorktree("wt-1", "feature/new"); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.workspaces[0].name).toBe("feature/new"); + + rejectRename(new Error("rename failed")); + + await act(async () => { + try { + await renameCall; + } catch { + // Expected rejection. + } + }); + + expect(result.current.workspaces[0].name).toBe("feature/old"); + expect(result.current.workspaces[0].worktree?.branch).toBe("feature/old"); + }); + + it("exposes upstream rename helper", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const renameWorktreeUpstreamMock = vi.mocked(renameWorktreeUpstream); + listWorkspacesMock.mockResolvedValue([worktree]); + renameWorktreeUpstreamMock.mockResolvedValue(undefined); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await result.current.renameWorktreeUpstream( + "wt-1", + "feature/old", + "feature/new", + ); + }); + + expect(renameWorktreeUpstreamMock).toHaveBeenCalledWith( + "wt-1", + "feature/old", + "feature/new", + ); + }); +}); diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index c1738aaeb..6e43fe37b 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -16,6 +16,8 @@ import { pickWorkspacePath, removeWorkspace as removeWorkspaceService, removeWorktree as removeWorktreeService, + renameWorktree as renameWorktreeService, + renameWorktreeUpstream as renameWorktreeUpstreamService, updateWorkspaceCodexBin as updateWorkspaceCodexBinService, updateWorkspaceSettings as updateWorkspaceSettingsService, } from "../../../services/tauri"; @@ -673,6 +675,81 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { } } + async function renameWorktree(workspaceId: string, branch: string) { + const trimmed = branch.trim(); + onDebug?.({ + id: `${Date.now()}-client-rename-worktree`, + timestamp: Date.now(), + source: "client", + label: "worktree/rename", + payload: { workspaceId, branch: trimmed }, + }); + let previous: WorkspaceInfo | null = null; + if (trimmed) { + setWorkspaces((prev) => + prev.map((entry) => { + if (entry.id !== workspaceId) { + return entry; + } + previous = entry; + return { + ...entry, + name: trimmed, + worktree: entry.worktree ? { ...entry.worktree, branch: trimmed } : { branch: trimmed }, + }; + }), + ); + } + try { + const updated = await renameWorktreeService(workspaceId, trimmed); + setWorkspaces((prev) => + prev.map((entry) => (entry.id === workspaceId ? updated : entry)), + ); + return updated; + } catch (error) { + if (previous) { + const restore = previous; + setWorkspaces((prev) => + prev.map((entry) => (entry.id === workspaceId ? restore : entry)), + ); + } + onDebug?.({ + id: `${Date.now()}-client-rename-worktree-error`, + timestamp: Date.now(), + source: "error", + label: "worktree/rename error", + payload: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async function renameWorktreeUpstream( + workspaceId: string, + oldBranch: string, + newBranch: string, + ) { + onDebug?.({ + id: `${Date.now()}-client-rename-worktree-upstream`, + timestamp: Date.now(), + source: "client", + label: "worktree/rename-upstream", + payload: { workspaceId, oldBranch, newBranch }, + }); + try { + await renameWorktreeUpstreamService(workspaceId, oldBranch, newBranch); + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-rename-worktree-upstream-error`, + timestamp: Date.now(), + source: "error", + label: "worktree/rename-upstream error", + payload: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + return { workspaces, workspaceGroups, @@ -696,6 +773,8 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { assignWorkspaceGroup, removeWorkspace, removeWorktree, + renameWorktree, + renameWorktreeUpstream, hasLoaded, refreshWorkspaces, }; diff --git a/src/services/tauri.ts b/src/services/tauri.ts index ad0b83090..090ed3168 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -97,6 +97,21 @@ export async function removeWorktree(id: string): Promise { return invoke("remove_worktree", { id }); } +export async function renameWorktree( + id: string, + branch: string, +): Promise { + return invoke("rename_worktree", { id, branch }); +} + +export async function renameWorktreeUpstream( + id: string, + oldBranch: string, + newBranch: string, +): Promise { + return invoke("rename_worktree_upstream", { id, oldBranch, newBranch }); +} + export async function applyWorktreeChanges(workspaceId: string): Promise { return invoke("apply_worktree_changes", { workspaceId }); } diff --git a/src/styles/main.css b/src/styles/main.css index 3ec2a592d..9c13c7cd2 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -458,6 +458,7 @@ } .worktree-info-subtle { + text-wrap: auto; font-size: 11px; color: var(--text-faint); } @@ -478,6 +479,60 @@ border-color: var(--border-strong); } +.worktree-info-rename { + display: flex; + flex-direction: column; + gap: 6px; +} + +.worktree-info-input { + border: 1px solid var(--border-muted); + background: rgba(12, 16, 26, 0.94); + color: var(--text-stronger); + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + flex: 1; +} + +.worktree-info-input:focus { + outline: none; + border-color: var(--border-strong); +} + +.worktree-info-confirm { + border: 1px solid var(--border-muted); + background: rgba(12, 16, 26, 0.94); + color: var(--text-stronger); + border-radius: 8px; + width: 28px; + height: 28px; + padding: 0; +} + +.worktree-info-confirm:hover:not(:disabled) { + border-color: var(--border-strong); +} + +.worktree-info-error { + font-size: 11px; + color: var(--text-danger); +} + +.worktree-info-upstream { + display: flex; + flex-direction: column; + gap: 6px; +} + +.worktree-info-upstream-button { + align-self: flex-start; + padding: 6px 10px; + font-size: 12px; + font-weight: 600; + border-radius: 8px; +} + .workspace-branch-menu { position: relative; display: inline-flex;