diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 353f75b9..6e489180 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -397,6 +397,8 @@ pub enum EventData { }, WorktreeRemoved { workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option, worktree: WorktreeInfo, forced: bool, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 3a3684e5..c8c8adbb 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -504,6 +504,7 @@ fn worktree_lifecycle_events_round_trip() { event: EventKind::WorktreeRemoved, data: EventData::WorktreeRemoved { workspace_id: "w_2".into(), + workspace: Some(workspace.clone()), worktree: WorktreeInfo { open_workspace_id: None, ..worktree.clone() diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index cd7d1818..92d0d4c6 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -63,14 +63,31 @@ impl App { }) }), EventData::WorkspaceRenamed { workspace_id, .. } - | EventData::WorkspaceFocused { workspace_id } - | EventData::WorktreeRemoved { workspace_id, .. } => self + | EventData::WorkspaceFocused { workspace_id } => self .plugin_context_for_workspace_id(workspace_id, correlation_id) .unwrap_or_else(|| { let mut context = empty_plugin_context(correlation_id); context.workspace_id = Some(workspace_id.clone()); context }), + EventData::WorktreeRemoved { + workspace_id, + workspace, + worktree, + .. + } => workspace + .as_ref() + .map(|workspace| { + self.plugin_context_for_workspace_snapshot(workspace, correlation_id) + }) + .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.workspace_label = Some(worktree.label.clone()); + context.workspace_cwd = Some(worktree.path.clone()); + context + }), EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id), EventData::TabClosed { tab_id, @@ -168,19 +185,27 @@ impl App { ) -> PluginInvocationContext { self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id) .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace.workspace_id.clone()); - context.workspace_label = Some(workspace.label.clone()); - context.workspace_cwd = workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.clone()); - context.worktree = workspace.worktree.clone(); - context.tab_id = Some(workspace.active_tab_id.clone()); - context + self.plugin_context_for_workspace_snapshot(workspace, correlation_id) }) } + fn plugin_context_for_workspace_snapshot( + &self, + workspace: &crate::api::schema::WorkspaceInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace.workspace_id.clone()); + context.workspace_label = Some(workspace.label.clone()); + context.workspace_cwd = workspace + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.clone()); + context.worktree = workspace.worktree.clone(); + context.tab_id = Some(workspace.active_tab_id.clone()); + context + } + fn plugin_context_for_tab_id( &self, tab_id: &str, diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 8c0e4212..833134b5 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -1906,6 +1906,76 @@ command = ["sh", "-c", "printf '%s' \"$HERDR_PLUGIN_CONTEXT_JSON\" > {}"] pane_context.focused_pane_id.as_deref(), Some(active_public_pane_id.as_str()) ); + + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + let workspace = app.workspace_info(0); + let worktree = crate::api::schema::WorktreeInfo { + path: "/repo/herdr-issue".into(), + branch: Some("worktree/issue".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: None, + label: "herdr".into(), + }; + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-other".into(), + is_linked_worktree: true, + }); + let changed_context = app.plugin_context_for_event( + &crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorktreeRemoved, + data: crate::api::schema::EventData::WorktreeRemoved { + workspace_id: workspace_id.clone(), + workspace: Some(workspace.clone()), + worktree: worktree.clone(), + forced: true, + }, + }, + "worktree.removed", + ); + assert_eq!( + changed_context + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.as_str()), + Some("/repo/herdr-issue") + ); + + app.state.workspaces.clear(); + let removed_context = app.plugin_context_for_event( + &crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorktreeRemoved, + data: crate::api::schema::EventData::WorktreeRemoved { + workspace_id: workspace_id.clone(), + workspace: Some(workspace), + worktree, + forced: true, + }, + }, + "worktree.removed", + ); + assert_eq!( + removed_context.workspace_id.as_deref(), + Some(workspace_id.as_str()) + ); + assert_eq!( + removed_context + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.as_str()), + Some("/repo/herdr-issue") + ); } #[cfg(unix)] diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 079c2ea4..6c14a330 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -122,20 +122,7 @@ impl App { self.state.mode = Mode::Terminal; } self.schedule_session_save(); - let tab = self.tab_info(ws_idx, tab_idx).unwrap(); - let root_pane = self - .root_pane_info(ws_idx, tab_idx) - .expect("new tab should have a root pane"); - self.emit_event(EventEnvelope { - event: EventKind::TabCreated, - data: EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(EventEnvelope { - event: EventKind::PaneCreated, - data: EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); + self.emit_tab_created_events(ws_idx, tab_idx); encode_success( id, self.tab_created_result(ws_idx, tab_idx) diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index cdfd4ec5..d1d7bf5c 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -63,29 +63,7 @@ impl App { crate::logging::workspace_renamed(&workspace.id); } } - let workspace = self.workspace_info(index); - let tab = self - .tab_info(index, 0) - .expect("new workspace should have an initial tab"); - let root_pane = self - .root_pane_info(index, 0) - .expect("new workspace should have an initial root pane"); - self.emit_event(EventEnvelope { - event: EventKind::WorkspaceCreated, - data: EventData::WorkspaceCreated { - workspace: workspace.clone(), - }, - }); - self.emit_event(EventEnvelope { - event: EventKind::TabCreated, - data: EventData::TabCreated { tab: tab.clone() }, - }); - self.emit_event(EventEnvelope { - event: EventKind::PaneCreated, - data: EventData::PaneCreated { - pane: root_pane.clone(), - }, - }); + self.emit_workspace_open_events(index); encode_success( id, self.workspace_created_result(index) diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 648b571a..abfa0c1e 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -146,15 +146,9 @@ impl App { self.emit_workspace_open_events(ws_idx); let worktree = self - .worktree_info_for_checkout(&source, ws_idx) + .worktree_info_for_workspace(ws_idx) .expect("created worktree workspace should have worktree info"); - self.emit_event(EventEnvelope { - event: EventKind::WorktreeCreated, - data: EventData::WorktreeCreated { - workspace: self.workspace_info(ws_idx), - worktree: worktree.clone(), - }, - }); + self.emit_worktree_created_event(ws_idx, worktree.clone()); encode_success( id, ResponseResult::WorktreeCreated { @@ -252,14 +246,7 @@ impl App { let tab_idx = self.state.workspaces[ws_idx].active_tab; let worktree = self.worktree_info_for_entry(&source, entry); - self.emit_event(EventEnvelope { - event: EventKind::WorktreeOpened, - data: EventData::WorktreeOpened { - workspace: self.workspace_info(ws_idx), - worktree: worktree.clone(), - already_open: already_open.is_some(), - }, - }); + self.emit_worktree_opened_event(ws_idx, worktree.clone(), already_open.is_some()); encode_success( id, ResponseResult::WorktreeOpened { @@ -355,18 +342,16 @@ impl App { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id: workspace_id.clone(), - workspace: Some(workspace_snapshot), + workspace: Some(workspace_snapshot.clone()), }, }); } - self.emit_event(EventEnvelope { - event: EventKind::WorktreeRemoved, - data: EventData::WorktreeRemoved { - workspace_id: workspace_id.clone(), - worktree, - forced: params.force, - }, - }); + self.emit_worktree_removed_event( + workspace_id.clone(), + Some(workspace_snapshot), + worktree, + params.force, + ); encode_success( id, @@ -653,7 +638,7 @@ impl App { self.set_worktree_membership(target_ws_idx, membership, emit_update); } - fn set_worktree_membership( + pub(crate) fn set_worktree_membership( &mut self, ws_idx: usize, membership: crate::workspace::WorktreeSpaceMembership, @@ -754,27 +739,7 @@ impl App { } } - fn worktree_info_for_checkout( - &self, - source: &WorktreeSource, - ws_idx: usize, - ) -> Option { - let membership = self.state.workspaces.get(ws_idx)?.worktree_space()?; - let branch = crate::workspace::git_branch(&membership.checkout_path); - let is_detached = branch.is_none(); - Some(WorktreeInfo { - path: membership.checkout_path.display().to_string(), - branch, - is_bare: false, - is_detached, - is_prunable: false, - is_linked_worktree: membership.is_linked_worktree, - open_workspace_id: Some(self.public_workspace_id(ws_idx)), - label: source.repo_name.clone(), - }) - } - - fn worktree_info_for_membership( + pub(crate) fn worktree_info_for_membership( &self, membership: &crate::workspace::WorktreeSpaceMembership, open_workspace_id: Option, @@ -793,7 +758,7 @@ impl App { } } - fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option { + pub(crate) fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option { let canonical_checkout = crate::worktree::canonical_or_original(checkout_path); let checkout_key = canonical_checkout.display().to_string(); self.state.workspaces.iter().position(|ws| { @@ -823,27 +788,59 @@ impl App { }) } - fn emit_workspace_open_events(&mut self, ws_idx: usize) { - let workspace_info = self.workspace_info(ws_idx); - let Some(tab) = self.tab_info(ws_idx, 0) else { - return; - }; - let Some(root_pane) = self.root_pane_info(ws_idx, 0) else { - return; - }; + pub(crate) fn worktree_info_for_workspace(&self, ws_idx: usize) -> Option { + let membership = self.state.workspaces.get(ws_idx)?.worktree_space()?; + Some(self.worktree_info_for_membership(membership, Some(self.public_workspace_id(ws_idx)))) + } + + pub(crate) fn emit_worktree_created_event(&mut self, ws_idx: usize, worktree: WorktreeInfo) { self.emit_event(EventEnvelope { - event: EventKind::WorkspaceCreated, - data: EventData::WorkspaceCreated { - workspace: workspace_info, + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: self.workspace_info(ws_idx), + worktree, }, }); + } + + pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) { + let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { + return; + }; + self.emit_worktree_opened_event(ws_idx, worktree, already_open); + } + + fn emit_worktree_opened_event( + &mut self, + ws_idx: usize, + worktree: WorktreeInfo, + already_open: bool, + ) { self.emit_event(EventEnvelope { - event: EventKind::TabCreated, - data: EventData::TabCreated { tab }, + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: self.workspace_info(ws_idx), + worktree, + already_open, + }, }); + } + + pub(crate) fn emit_worktree_removed_event( + &mut self, + workspace_id: String, + workspace: Option, + worktree: WorktreeInfo, + forced: bool, + ) { self.emit_event(EventEnvelope { - event: EventKind::PaneCreated, - data: EventData::PaneCreated { pane: root_pane }, + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id, + workspace, + worktree, + forced, + }, }); } @@ -1033,7 +1030,8 @@ mod tests { .is_linked_worktree ); assert!(workspace.worktree.unwrap().is_linked_worktree); - assert!(event_hub.events_after(0).iter().any(|(_, event)| { + let events = event_hub.events_after(0); + assert!(events.iter().any(|(_, event)| { matches!( &event.data, EventData::WorktreeCreated { @@ -1044,6 +1042,26 @@ mod tests { && event_worktree.is_linked_worktree ) })); + let kinds = events + .iter() + .map(|(_, event)| event.event) + .collect::>(); + assert_eq!( + kinds + .iter() + .filter(|event| **event == EventKind::WorktreeCreated) + .count(), + 1 + ); + assert_eq!( + &kinds[kinds.len() - 4..], + &[ + EventKind::WorkspaceCreated, + EventKind::TabCreated, + EventKind::PaneCreated, + EventKind::WorktreeCreated, + ] + ); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); @@ -1674,9 +1692,11 @@ mod tests { &event.data, EventData::WorktreeRemoved { workspace_id, + workspace: Some(workspace), worktree, forced, } if workspace_id == &child_id + && workspace.workspace_id == child_id && worktree.branch.as_deref() == Some("worktree/api-remove-event") && worktree.is_linked_worktree && worktree.open_workspace_id.is_none() diff --git a/src/app/creation.rs b/src/app/creation.rs index 1d9b0f37..bdd1a43b 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crate::api::schema::{EventData, EventEnvelope, EventKind}; use tracing::error; use super::{ @@ -61,7 +62,7 @@ impl App { .workspace_creation_source() .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); - if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { + if let Err(e) = self.create_workspace_with_events(initial_cwd, true) { error!(err = %e, "failed to create workspace"); self.state.mode = Mode::Navigate; } @@ -69,18 +70,24 @@ impl App { pub(crate) fn create_tab(&mut self) { let custom_name = self.state.requested_new_tab_name.take(); + let active_before = self.state.active; let follow_cwd = self .state .active .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)); let initial_cwd = self.resolve_new_terminal_cwd(follow_cwd); match self.create_tab_with_options(initial_cwd, true) { - Ok(tab_idx) => { + Ok(created_idx) => { + let created_workspace = active_before.is_none(); + let ws_idx = if created_workspace { + Some(created_idx) + } else { + self.state.active + }; + let tab_idx = if created_workspace { 0 } else { created_idx }; if let Some(name) = custom_name { - if let Some(ws) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) + if let Some(ws) = + ws_idx.and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) { if let Some(tab) = ws.tabs.get_mut(tab_idx) { tab.set_custom_name(name); @@ -88,6 +95,13 @@ impl App { self.schedule_session_save(); } } + if let Some(ws_idx) = ws_idx { + if created_workspace { + self.emit_workspace_open_events(ws_idx); + } else { + self.emit_tab_created_events(ws_idx, tab_idx); + } + } } Err(e) => { error!(err = %e, "failed to create tab"); @@ -140,6 +154,16 @@ impl App { self.create_workspace_with_launch_env(initial_cwd, focus, Vec::new()) } + pub(crate) fn create_workspace_with_events( + &mut self, + initial_cwd: PathBuf, + focus: bool, + ) -> std::io::Result<()> { + let ws_idx = self.create_workspace_with_options(initial_cwd, focus)?; + self.emit_workspace_open_events(ws_idx); + Ok(()) + } + pub(crate) fn create_workspace_with_launch_env( &mut self, initial_cwd: PathBuf, @@ -244,6 +268,48 @@ impl App { }) } + pub(crate) fn emit_workspace_open_events(&mut self, ws_idx: usize) { + let workspace_info = self.workspace_info(ws_idx); + let Some(tab) = self.tab_info(ws_idx, 0) else { + return; + }; + let Some(root_pane) = self.root_pane_info(ws_idx, 0) else { + return; + }; + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceCreated, + data: EventData::WorkspaceCreated { + workspace: workspace_info, + }, + }); + self.emit_tab_and_pane_created_events(tab, root_pane); + } + + pub(crate) fn emit_tab_created_events(&mut self, ws_idx: usize, tab_idx: usize) { + let Some(tab) = self.tab_info(ws_idx, tab_idx) else { + return; + }; + let Some(root_pane) = self.root_pane_info(ws_idx, tab_idx) else { + return; + }; + self.emit_tab_and_pane_created_events(tab, root_pane); + } + + fn emit_tab_and_pane_created_events( + &mut self, + tab: crate::api::schema::TabInfo, + root_pane: crate::api::schema::PaneInfo, + ) { + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane: root_pane }, + }); + } + pub(super) fn workspace_created_result( &self, ws_idx: usize, diff --git a/src/app/mod.rs b/src/app/mod.rs index c8e3eb55..dbc2d416 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -879,7 +879,7 @@ impl App { } if let Some(cwd) = self.state.request_new_workspace_cwd.take() { - if let Err(err) = self.create_workspace_with_options(cwd, true) { + if let Err(err) = self.create_workspace_with_events(cwd, true) { tracing::error!(err = %err, "failed to create workspace at requested cwd"); self.state.mode = Mode::Navigate; } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index bcfaf6d8..7fcfe9d9 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -358,7 +358,7 @@ impl App { let repo_name = open.repo_name.clone(); self.state.worktree_open = None; - if let Some(ws_idx) = entry.already_open_ws_idx { + if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&entry.path) { self.mark_opened_existing_worktree_membership( &source_workspace_id, source_existing_membership, @@ -372,22 +372,43 @@ impl App { ); self.state.switch_workspace(ws_idx); self.state.mode = Mode::Terminal; + self.emit_worktree_opened_for_workspace(ws_idx, true); return; } + if let Some(source_ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == source_workspace_id) + { + let source_membership = source_existing_membership.clone().unwrap_or( + crate::workspace::WorktreeSpaceMembership { + key: repo_key.clone(), + label: repo_name.clone(), + repo_root: source_repo_root.clone(), + checkout_path: source_checkout_path.clone(), + is_linked_worktree: false, + }, + ); + self.set_worktree_membership(source_ws_idx, source_membership, true); + } + match self.create_workspace_with_options(entry.path.clone(), true) { Ok(new_ws_idx) => { - self.mark_opened_existing_worktree_membership( - &source_workspace_id, - source_existing_membership, - source_checkout_path, - source_repo_root, - repo_key, - repo_name, + self.set_worktree_membership( new_ws_idx, - entry.path, - entry.is_linked_worktree, + crate::workspace::WorktreeSpaceMembership { + key: repo_key, + label: repo_name, + repo_root: source_repo_root, + checkout_path: entry.path, + is_linked_worktree: entry.is_linked_worktree, + }, + false, ); + self.emit_workspace_open_events(new_ws_idx); + self.emit_worktree_opened_for_workspace(new_ws_idx, false); } Err(err) => { self.state.worktree_open = Some(WorktreeOpenState { @@ -429,29 +450,27 @@ impl App { .iter() .position(|ws| ws.id == source_workspace_id) { - if let Some(source_membership) = source_existing_membership { - self.state.workspaces[source_ws_idx].worktree_space = Some(source_membership); - } else { - self.state.workspaces[source_ws_idx].worktree_space = - Some(crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }); - } + let source_membership = + source_existing_membership.unwrap_or(crate::workspace::WorktreeSpaceMembership { + key: repo_key.clone(), + label: repo_name.clone(), + repo_root: source_repo_root.clone(), + checkout_path: source_checkout_path, + is_linked_worktree: false, + }); + self.set_worktree_membership(source_ws_idx, source_membership, true); } - if let Some(target) = self.state.workspaces.get_mut(target_ws_idx) { - target.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + self.set_worktree_membership( + target_ws_idx, + crate::workspace::WorktreeSpaceMembership { key: repo_key, label: repo_name, repo_root: source_repo_root, checkout_path: target_path, is_linked_worktree: target_is_linked_worktree, - }); - } - self.state.mark_session_dirty(); + }, + true, + ); } fn close_worktree_create_dialog(&mut self) { @@ -595,6 +614,21 @@ impl App { self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); } + let (workspace_snapshot, worktree_snapshot) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == workspace_id) + .map(|ws_idx| { + let workspace = Box::new(self.workspace_info(ws_idx)); + let worktree = self.state.workspaces[ws_idx] + .worktree_space() + .cloned() + .map(|space| Box::new(self.worktree_info_for_membership(&space, None))); + (Some(workspace), worktree) + }) + .unwrap_or((None, None)); + let command = crate::worktree::build_worktree_remove_command(&repo_root, &path, force); tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove"); let event_tx = self.event_tx.clone(); @@ -604,6 +638,9 @@ impl App { event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(WorktreeRemoveResult { workspace_id, path, + workspace: workspace_snapshot, + worktree: worktree_snapshot, + forced: force, result, })); }); @@ -630,41 +667,65 @@ impl App { self.state.worktree_create = None; self.state.name_input.clear(); self.state.name_input_replace_on_type = false; - match self.create_workspace_with_options(path.clone(), true) { - Ok(ws_idx) => { - let source_membership = source_existing_membership.unwrap_or( - crate::workspace::WorktreeSpaceMembership { - key: repo_key.clone(), - label: repo_name.clone(), - repo_root: source_repo_root.clone(), - checkout_path: source_checkout_path, - is_linked_worktree: false, - }, - ); - if let Some(ws) = self - .state - .workspaces - .iter_mut() - .find(|ws| ws.id == source_workspace_id) - { - ws.worktree_space = Some(source_membership); - } - if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { - ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: repo_key, - label: repo_name, - repo_root: source_repo_root, - checkout_path: path, - is_linked_worktree: true, - }); - } - self.state.mark_session_dirty(); + let source_membership = source_existing_membership.unwrap_or( + crate::workspace::WorktreeSpaceMembership { + key: repo_key.clone(), + label: repo_name.clone(), + repo_root: source_repo_root.clone(), + checkout_path: source_checkout_path, + is_linked_worktree: false, + }, + ); + if let Some(source_ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == source_workspace_id) + { + self.set_worktree_membership(source_ws_idx, source_membership, true); + } + if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&path) { + self.set_worktree_membership( + ws_idx, + crate::workspace::WorktreeSpaceMembership { + key: repo_key, + label: repo_name, + repo_root: source_repo_root, + checkout_path: path, + is_linked_worktree: true, + }, + true, + ); + self.state.switch_workspace(ws_idx); + self.state.mode = Mode::Terminal; + if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { + self.emit_worktree_created_event(ws_idx, worktree); } - Err(err) => { - self.state.config_diagnostic = Some(format!( - "created worktree but failed to open workspace: {err}" - )); - self.state.mode = Mode::Navigate; + } else { + match self.create_workspace_with_options(path.clone(), true) { + Ok(ws_idx) => { + self.set_worktree_membership( + ws_idx, + crate::workspace::WorktreeSpaceMembership { + key: repo_key, + label: repo_name, + repo_root: source_repo_root, + checkout_path: path, + is_linked_worktree: true, + }, + false, + ); + self.emit_workspace_open_events(ws_idx); + if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) { + self.emit_worktree_created_event(ws_idx, worktree); + } + } + Err(err) => { + self.state.config_diagnostic = Some(format!( + "created worktree but failed to open workspace: {err}" + )); + self.state.mode = Mode::Navigate; + } } } self.render_dirty.store(true, Ordering::Release); @@ -690,13 +751,25 @@ impl App { match result.result { Ok(()) => { tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed"); + let forced = result.forced; self.state.worktree_remove = None; + let mut workspace_id = result.workspace_id.clone(); + let mut workspace_snapshot = result.workspace.as_deref().cloned(); + let mut worktree = result.worktree.as_deref().cloned(); if let Some(ws_idx) = self .state .workspaces .iter() .position(|ws| ws.id == result.workspace_id) { + workspace_id = self.public_workspace_id(ws_idx); + workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx)); + if worktree.is_none() { + worktree = self.state.workspaces[ws_idx] + .worktree_space() + .cloned() + .map(|space| self.worktree_info_for_membership(&space, None)); + } let still_same_linked_worktree = self.state.workspaces[ws_idx] .worktree_space() .is_some_and(|space| { @@ -705,7 +778,25 @@ impl App { if still_same_linked_worktree { self.state.selected = ws_idx; self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceClosed, + data: crate::api::schema::EventData::WorkspaceClosed { + workspace_id: workspace_id.clone(), + workspace: workspace_snapshot.clone(), + }, + }); } + } else if let Some(snapshot) = workspace_snapshot.as_ref() { + workspace_id = snapshot.workspace_id.clone(); + } + if let Some(worktree) = worktree { + self.emit_worktree_removed_event( + workspace_id, + workspace_snapshot, + worktree, + forced, + ); } self.state.mode = if self.state.active.is_some() { Mode::Terminal @@ -801,15 +892,70 @@ mod tests { } fn app_for_worktree_tests() -> App { + app_for_worktree_tests_with_event_hub(crate::api::EventHub::default()) + } + + fn app_for_worktree_tests_with_event_hub(event_hub: crate::api::EventHub) -> App { App::new( &crate::config::Config::default(), true, None, tokio::sync::mpsc::unbounded_channel().1, - crate::api::EventHub::default(), + event_hub, ) } + fn event_kinds(event_hub: &crate::api::EventHub) -> Vec { + event_hub + .events_after(0) + .into_iter() + .map(|(_, event)| event.event) + .collect() + } + + fn shutdown_test_runtimes(app: &mut App) { + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn ui_create_workspace_emits_initial_workspace_tab_and_pane_events() { + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + + app.create_workspace(); + + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::WorkspaceCreated, + crate::api::schema::EventKind::TabCreated, + crate::api::schema::EventKind::PaneCreated, + ] + ); + shutdown_test_runtimes(&mut app); + } + + #[tokio::test] + async fn ui_create_tab_emits_tab_and_pane_events() { + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.create_workspace_with_options(std::env::temp_dir(), true) + .unwrap(); + + app.create_tab(); + + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::TabCreated, + crate::api::schema::EventKind::PaneCreated, + ] + ); + shutdown_test_runtimes(&mut app); + } + #[test] fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() { let mut app = app_for_worktree_tests(); @@ -913,6 +1059,7 @@ mod tests { crate::workspace::Workspace::test_new("main"), crate::workspace::Workspace::test_new("issue"), ]; + app.state.workspaces[1].identity_cwd = "/repo/herdr-issue".into(); app.state.active = Some(0); app.state.selected = 0; app.state.worktree_open = Some(WorktreeOpenState { @@ -949,6 +1096,114 @@ mod tests { assert!(target_membership.is_linked_worktree); } + #[tokio::test] + async fn ui_worktree_open_new_workspace_emits_api_parity_events() { + let checkout = unique_temp_path("app-worktree-open-event-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; + let source_workspace_id = app.state.workspaces[0].id.clone(); + let source_membership = crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }; + app.state.workspaces[0].worktree_space = Some(source_membership.clone()); + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id, + source_existing_membership: Some(source_membership), + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: vec![WorktreeOpenEntry { + path: checkout.clone(), + branch: Some("worktree/open-event".into()), + is_linked_worktree: true, + already_open_ws_idx: None, + }], + selected: 0, + query: String::new(), + search_focused: false, + error: None, + }); + + app.open_selected_existing_worktree(); + + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::WorkspaceCreated, + crate::api::schema::EventKind::TabCreated, + crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::WorktreeOpened, + ] + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(checkout); + } + + #[tokio::test] + async fn open_selected_existing_worktree_recomputes_stale_already_open_state() { + let checkout = unique_temp_path("app-worktree-stale-open-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("source"), + crate::workspace::Workspace::test_new("other"), + ]; + let source_workspace_id = app.state.workspaces[0].id.clone(); + app.state.worktree_open = Some(WorktreeOpenState { + source_workspace_id, + source_existing_membership: None, + source_checkout_path: "/repo/herdr".into(), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + entries: vec![WorktreeOpenEntry { + path: checkout.clone(), + branch: Some("worktree/stale-open".into()), + is_linked_worktree: true, + already_open_ws_idx: Some(1), + }], + selected: 0, + query: String::new(), + search_focused: false, + error: None, + }); + + app.open_selected_existing_worktree(); + + assert_eq!(app.state.workspaces.len(), 3); + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::WorkspaceUpdated, + crate::api::schema::EventKind::WorkspaceCreated, + crate::api::schema::EventKind::TabCreated, + crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::WorktreeOpened, + ] + ); + let opened = event_hub + .events_after(0) + .into_iter() + .find_map(|(_, event)| match event.data { + crate::api::schema::EventData::WorktreeOpened { already_open, .. } => { + Some(already_open) + } + _ => None, + }) + .expect("worktree.opened should be emitted"); + assert!(!opened); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(checkout); + } + #[test] fn worktree_open_search_filters_entries() { let mut app = app_for_worktree_tests(); @@ -1116,6 +1371,197 @@ mod tests { assert_eq!(create.error, None); } + #[tokio::test] + async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { + let repo = create_committed_repo("app-worktree-create-event-repo"); + let worktree_root = unique_temp_path("app-worktree-create-event-root"); + let branch = "worktree/ui-create-event"; + let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + branch, + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; + let source_workspace_id = app.state.workspaces[0].id.clone(); + let source_membership = crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: repo.clone(), + checkout_path: repo.clone(), + is_linked_worktree: false, + }; + app.state.workspaces[0].worktree_space = Some(source_membership.clone()); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id, + source_checkout_path: repo.clone(), + source_existing_membership: Some(source_membership), + source_repo_root: repo.clone(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: branch.into(), + checkout_path: checkout.clone(), + error: None, + creating: true, + }); + let plugin_root = unique_temp_path("app-worktree-create-plugin"); + std::fs::create_dir_all(&plugin_root).unwrap(); + let manifest_path = plugin_root.join("herdr-plugin.toml"); + std::fs::write(&manifest_path, "id = 'example.ui-worktree-create'\n").unwrap(); + app.state.installed_plugins.insert( + "example.ui-worktree-create".into(), + crate::api::schema::InstalledPluginInfo { + plugin_id: "example.ui-worktree-create".into(), + name: "UI Worktree Create".into(), + version: "0.1.0".into(), + min_herdr_version: "0.7.0".into(), + description: None, + manifest_path: manifest_path.display().to_string(), + plugin_root: plugin_root.display().to_string(), + enabled: true, + platforms: None, + build: Vec::new(), + actions: Vec::new(), + events: vec![crate::api::schema::PluginManifestEventHook { + on: "worktree.created".into(), + platforms: None, + command: vec!["sh".into(), "-c".into(), "true".into()], + }], + panes: Vec::new(), + link_handlers: Vec::new(), + source: crate::api::schema::PluginSourceInfo::default(), + warnings: Vec::new(), + }, + ); + + app.handle_worktree_add_finished(WorktreeAddResult { + path: checkout.clone(), + result: Ok(()), + }); + + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::WorkspaceCreated, + crate::api::schema::EventKind::TabCreated, + crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::WorktreeCreated, + ] + ); + let events = event_hub.events_after(0); + let workspace_created = events + .iter() + .find(|(_, event)| event.event == crate::api::schema::EventKind::WorkspaceCreated) + .map(|(_, event)| event) + .expect("workspace.created should be emitted"); + let crate::api::schema::EventData::WorkspaceCreated { workspace } = &workspace_created.data + else { + panic!("unexpected event data"); + }; + let checkout_path = checkout.display().to_string(); + assert_eq!( + workspace + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.as_str()), + Some(checkout_path.as_str()) + ); + let worktree_created = events + .iter() + .find(|(_, event)| event.event == crate::api::schema::EventKind::WorktreeCreated) + .map(|(_, event)| event) + .expect("worktree.created should be emitted"); + let crate::api::schema::EventData::WorktreeCreated { + workspace, + worktree, + } = &worktree_created.data + else { + panic!("unexpected event data"); + }; + assert_eq!( + workspace + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.as_str()), + Some(checkout_path.as_str()) + ); + assert_eq!( + worktree.open_workspace_id.as_deref(), + Some(workspace.workspace_id.as_str()) + ); + assert!(app.state.plugin_command_logs.iter().any(|log| { + log.event.as_deref() == Some("worktree.created") + && log.status == crate::api::schema::PluginCommandStatus::Running + })); + + shutdown_test_runtimes(&mut app); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + crate::worktree::run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(worktree_root); + let _ = std::fs::remove_dir_all(repo); + let _ = std::fs::remove_dir_all(plugin_root); + } + + #[test] + fn worktree_create_finished_reuses_checkout_opened_before_result() { + let checkout = unique_temp_path("app-worktree-create-race-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("source"), + crate::workspace::Workspace::test_new("opened-by-race"), + ]; + let source_workspace_id = app.state.workspaces[0].id.clone(); + app.state.workspaces[1].identity_cwd = checkout.clone(); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id, + source_checkout_path: "/repo/herdr".into(), + source_existing_membership: None, + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: "worktree/create-race".into(), + checkout_path: checkout.clone(), + error: None, + creating: true, + }); + + app.handle_worktree_add_finished(WorktreeAddResult { + path: checkout.clone(), + result: Ok(()), + }); + + assert_eq!(app.state.workspaces.len(), 2); + let kinds = event_kinds(&event_hub); + assert!(!kinds.contains(&crate::api::schema::EventKind::WorkspaceCreated)); + assert_eq!( + kinds + .iter() + .filter(|kind| **kind == crate::api::schema::EventKind::WorktreeCreated) + .count(), + 1 + ); + assert_eq!( + app.state.workspaces[1] + .worktree_space() + .map(|membership| membership.checkout_path.as_path()), + Some(checkout.as_path()) + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(checkout); + } + #[test] fn start_worktree_add_runs_git_on_worker_and_emits_result() { let repo = create_committed_repo("app-worktree-add-repo"); @@ -1286,6 +1732,9 @@ mod tests { app.handle_worktree_remove_finished(WorktreeRemoveResult { workspace_id: "ws".into(), path, + workspace: None, + worktree: None, + forced: false, result: Err( "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it" .into(), @@ -1314,6 +1763,9 @@ mod tests { app.handle_worktree_remove_finished(WorktreeRemoveResult { workspace_id: "ws".into(), path, + workspace: None, + worktree: None, + forced: false, result: Err("fatal: '/w/herdr/missing' is not a working tree".into()), }); @@ -1326,6 +1778,70 @@ mod tests { ); } + #[test] + fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() { + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + let internal_workspace_id = app.state.workspaces[0].id.clone(); + let checkout = std::path::PathBuf::from("/repo/herdr-issue"); + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + let workspace_snapshot = app.workspace_info(0); + let worktree_snapshot = crate::api::schema::WorktreeInfo { + path: checkout.display().to_string(), + branch: Some("worktree/issue".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: None, + label: "herdr".into(), + }; + app.state.worktree_remove = Some(WorktreeRemoveState { + workspace_id: internal_workspace_id.clone(), + repo_root: "/repo/herdr".into(), + path: checkout.clone(), + error: None, + removing: true, + force_confirmation: true, + }); + app.state.workspaces.clear(); + + app.handle_worktree_remove_finished(WorktreeRemoveResult { + workspace_id: internal_workspace_id, + path: checkout, + workspace: Some(Box::new(workspace_snapshot.clone())), + worktree: Some(Box::new(worktree_snapshot)), + forced: true, + result: Ok(()), + }); + + assert_eq!( + event_kinds(&event_hub), + vec![crate::api::schema::EventKind::WorktreeRemoved] + ); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + crate::api::schema::EventData::WorktreeRemoved { + workspace_id, + workspace: Some(workspace), + worktree, + forced, + } if workspace_id == &workspace_snapshot.workspace_id + && workspace.workspace_id == workspace_snapshot.workspace_id + && worktree.branch.as_deref() == Some("worktree/issue") + && *forced + ) + })); + } + #[test] fn dirty_worktree_remove_retries_with_force_and_closes_workspace() { let repo = create_committed_repo("app-worktree-dirty-remove-repo"); @@ -1344,7 +1860,8 @@ mod tests { ); std::fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - let mut app = app_for_worktree_tests(); + let event_hub = crate::api::EventHub::default(); + let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone()); app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; let workspace_id = app.state.workspaces[0].id.clone(); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { @@ -1394,6 +1911,21 @@ mod tests { assert!(!checkout.exists()); assert!(app.state.worktree_remove.is_none()); assert!(app.state.workspaces.is_empty()); + assert_eq!( + event_kinds(&event_hub), + vec![ + crate::api::schema::EventKind::WorkspaceClosed, + crate::api::schema::EventKind::WorktreeRemoved, + ] + ); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + crate::api::schema::EventData::WorktreeRemoved { worktree, .. } + if worktree.branch.as_deref() == Some("worktree/dirty-remove") + && !worktree.is_detached + ) + })); let _ = std::fs::remove_dir_all(repo); } diff --git a/src/events.rs b/src/events.rs index 31a16ee6..558d826e 100644 --- a/src/events.rs +++ b/src/events.rs @@ -19,6 +19,9 @@ pub struct WorktreeAddResult { pub struct WorktreeRemoveResult { pub workspace_id: String, pub path: std::path::PathBuf, + pub workspace: Option>, + pub worktree: Option>, + pub forced: bool, pub result: Result<(), String>, } diff --git a/src/server/headless.rs b/src/server/headless.rs index b162b8fe..aeda3a3b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -541,7 +541,7 @@ impl HeadlessServer { } if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { - if let Err(err) = self.app.create_workspace_with_options(cwd, true) { + if let Err(err) = self.app.create_workspace_with_events(cwd, true) { error!(err = %err, "failed to create workspace at requested cwd"); self.app.state.mode = app::Mode::Navigate; }