aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 16:05:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 16:05:43 +0300
commitd74ba8ca9e49d9d94a4e59a0ee0bdbd1128af76e (patch)
tree13473d5a8c3fd137f40e645e9f9eff3081ce3238 /src/app
parentb7a504b35e54e68ca7aceb01d21f066124f7a536 (diff)
fix: emit plugin lifecycle events from ui worktree flows
refs #662
Diffstat (limited to 'src/app')
-rw-r--r--src/app/api/plugins/context.rs49
-rw-r--r--src/app/api/plugins/mod.rs70
-rw-r--r--src/app/api/tabs.rs15
-rw-r--r--src/app/api/workspaces.rs24
-rw-r--r--src/app/api/worktrees.rs148
-rw-r--r--src/app/creation.rs78
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/worktrees.rs656
8 files changed, 860 insertions, 182 deletions
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<WorktreeInfo> {
- 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<String>,
@@ -793,7 +758,7 @@ impl App {
}
}
- fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> {
+ pub(crate) fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> {
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<WorktreeInfo> {
+ 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<crate::api::schema::WorkspaceInfo>,
+ 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::<Vec<_>>();
+ 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);
+ 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);
+ }
+ } 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);
+ }
}
- 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,
- });
+ Err(err) => {
+ self.state.config_diagnostic = Some(format!(
+ "created worktree but failed to open workspace: {err}"
+ ));
+ self.state.mode = Mode::Navigate;
}
- self.state.mark_session_dirty();
- }
- 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<crate::api::schema::EventKind> {
+ 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()),
});
@@ -1327,6 +1779,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");
let checkout = unique_temp_path("app-worktree-dirty-remove-checkout");
@@ -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);
}