fix: emit plugin lifecycle events from ui worktree flows

refs #662
This commit is contained in:
Ogulcan Celik
2026-06-18 16:05:43 +03:00
parent b7a504b35e
commit d74ba8ca9e
12 changed files with 868 additions and 184 deletions
+2
View File
@@ -397,6 +397,8 @@ pub enum EventData {
},
WorktreeRemoved {
workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace: Option<WorkspaceInfo>,
worktree: WorktreeInfo,
forced: bool,
},
+1
View File
@@ -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()
+37 -12
View File
@@ -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,
+70
View File
@@ -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)]
+1 -14
View File
@@ -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)
+1 -23
View File
@@ -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)
+84 -64
View File
@@ -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()
+72 -6
View File
@@ -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,
+1 -1
View File
@@ -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;
}
+595 -63
View File
@@ -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<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()),
});
@@ -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);
}
+3
View File
@@ -19,6 +19,9 @@ pub struct WorktreeAddResult {
pub struct WorktreeRemoveResult {
pub workspace_id: String,
pub path: std::path::PathBuf,
pub workspace: Option<Box<crate::api::schema::WorkspaceInfo>>,
pub worktree: Option<Box<crate::api::schema::WorktreeInfo>>,
pub forced: bool,
pub result: Result<(), String>,
}
+1 -1
View File
@@ -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;
}