mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-27 16:01:13 +00:00
feat: add worktree lifecycle events
This commit is contained in:
@@ -213,6 +213,8 @@ Remove a linked checkout:
|
||||
|
||||
Use at most one of `workspace_id` or `cwd` for `worktree.list`, `worktree.create`, and `worktree.open`; omit both to use the active workspace. Use exactly one of `path` or `branch` for `worktree.open`. Raw socket `cwd` and `path` values must be absolute; the CLI expands relative `--cwd` and `--path` values before sending requests. Workspace responses include optional `worktree` provenance when a workspace belongs to a Herdr worktree group. Worktree commands can emit `workspace.updated` when an existing workspace gains or changes worktree provenance.
|
||||
|
||||
Worktree commands also emit lifecycle events. `worktree.create` emits `workspace.created`, `tab.created`, `pane.created`, and `worktree.created`. `worktree.open` emits `worktree.opened`, and it also emits workspace/tab/pane creation events when it opens a new Herdr workspace. `worktree.remove` emits `worktree.removed`; if the linked workspace is still open, it also emits `workspace.closed`.
|
||||
|
||||
## Plugin APIs
|
||||
|
||||
The plugin API is an early host surface for local workflow tools. It does not require a manifest yet. A plugin process can register actions, store namespaced JSON records, and ask Herdr to open a managed terminal pane for its UI.
|
||||
@@ -392,12 +394,14 @@ Subscribe to events when you need a long-lived stream:
|
||||
|
||||
The first response acknowledges the subscription. Later lines are pushed events.
|
||||
|
||||
Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`.
|
||||
Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`. Workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal.
|
||||
Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`,
|
||||
`pane.moved`, `pane.exited`, `pane.agent_detected`,
|
||||
`pane.output_matched`, and `pane.agent_status_changed`.
|
||||
|
||||
Use `events.wait` when you want one matching event and then a response.
|
||||
Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`.
|
||||
|
||||
Use `events.subscribe` for lifecycle events. Dedicated wait helpers are documented separately when a one-shot wait is supported.
|
||||
|
||||
## Reading panes
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::common::{AgentStatus, ReadSource};
|
||||
use super::panes::{PaneInfo, PaneReadResult};
|
||||
use super::tabs::TabInfo;
|
||||
use super::workspaces::WorkspaceInfo;
|
||||
use super::worktrees::WorktreeInfo;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EventsSubscribeParams {
|
||||
@@ -25,6 +26,12 @@ pub enum Subscription {
|
||||
WorkspaceClosed {},
|
||||
#[serde(rename = "workspace.focused")]
|
||||
WorkspaceFocused {},
|
||||
#[serde(rename = "worktree.created")]
|
||||
WorktreeCreated {},
|
||||
#[serde(rename = "worktree.opened")]
|
||||
WorktreeOpened {},
|
||||
#[serde(rename = "worktree.removed")]
|
||||
WorktreeRemoved {},
|
||||
#[serde(rename = "tab.created")]
|
||||
TabCreated {},
|
||||
#[serde(rename = "tab.closed")]
|
||||
@@ -165,6 +172,9 @@ pub enum EventKind {
|
||||
WorkspaceClosed,
|
||||
WorkspaceRenamed,
|
||||
WorkspaceFocused,
|
||||
WorktreeCreated,
|
||||
WorktreeOpened,
|
||||
WorktreeRemoved,
|
||||
TabCreated,
|
||||
TabClosed,
|
||||
TabRenamed,
|
||||
@@ -240,6 +250,8 @@ pub enum EventData {
|
||||
},
|
||||
WorkspaceClosed {
|
||||
workspace_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workspace: Option<WorkspaceInfo>,
|
||||
},
|
||||
WorkspaceRenamed {
|
||||
workspace_id: String,
|
||||
@@ -248,6 +260,20 @@ pub enum EventData {
|
||||
WorkspaceFocused {
|
||||
workspace_id: String,
|
||||
},
|
||||
WorktreeCreated {
|
||||
workspace: WorkspaceInfo,
|
||||
worktree: WorktreeInfo,
|
||||
},
|
||||
WorktreeOpened {
|
||||
workspace: WorkspaceInfo,
|
||||
worktree: WorktreeInfo,
|
||||
already_open: bool,
|
||||
},
|
||||
WorktreeRemoved {
|
||||
workspace_id: String,
|
||||
worktree: WorktreeInfo,
|
||||
forced: bool,
|
||||
},
|
||||
TabCreated {
|
||||
tab: TabInfo,
|
||||
},
|
||||
|
||||
@@ -516,6 +516,94 @@ fn worktree_request_and_response_round_trip() {
|
||||
assert_eq!(restored, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_lifecycle_events_round_trip() {
|
||||
let subscription = Request {
|
||||
id: "sub_worktrees".into(),
|
||||
method: Method::EventsSubscribe(EventsSubscribeParams {
|
||||
subscriptions: vec![
|
||||
Subscription::WorktreeCreated {},
|
||||
Subscription::WorktreeOpened {},
|
||||
Subscription::WorktreeRemoved {},
|
||||
],
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&subscription).unwrap();
|
||||
assert!(json.contains("\"type\":\"worktree.created\""));
|
||||
assert!(json.contains("\"type\":\"worktree.opened\""));
|
||||
assert!(json.contains("\"type\":\"worktree.removed\""));
|
||||
let restored: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored, subscription);
|
||||
|
||||
let workspace = WorkspaceInfo {
|
||||
workspace_id: "w_2".into(),
|
||||
number: 2,
|
||||
label: "herdr".into(),
|
||||
focused: true,
|
||||
pane_count: 1,
|
||||
tab_count: 1,
|
||||
active_tab_id: "w_2:1".into(),
|
||||
agent_status: AgentStatus::Unknown,
|
||||
worktree: Some(WorkspaceWorktreeInfo {
|
||||
repo_key: "/repo/herdr/.git".into(),
|
||||
repo_name: "herdr".into(),
|
||||
repo_root: "/repo/herdr".into(),
|
||||
checkout_path: "/worktrees/herdr/worktree-api".into(),
|
||||
is_linked_worktree: true,
|
||||
}),
|
||||
};
|
||||
let worktree = WorktreeInfo {
|
||||
path: "/worktrees/herdr/worktree-api".into(),
|
||||
branch: Some("worktree/api".into()),
|
||||
is_bare: false,
|
||||
is_detached: false,
|
||||
is_prunable: false,
|
||||
is_linked_worktree: true,
|
||||
open_workspace_id: Some("w_2".into()),
|
||||
label: "herdr".into(),
|
||||
};
|
||||
|
||||
for event in [
|
||||
EventEnvelope {
|
||||
event: EventKind::WorktreeCreated,
|
||||
data: EventData::WorktreeCreated {
|
||||
workspace: workspace.clone(),
|
||||
worktree: worktree.clone(),
|
||||
},
|
||||
},
|
||||
EventEnvelope {
|
||||
event: EventKind::WorktreeOpened,
|
||||
data: EventData::WorktreeOpened {
|
||||
workspace: workspace.clone(),
|
||||
worktree: worktree.clone(),
|
||||
already_open: false,
|
||||
},
|
||||
},
|
||||
EventEnvelope {
|
||||
event: EventKind::WorktreeRemoved,
|
||||
data: EventData::WorktreeRemoved {
|
||||
workspace_id: "w_2".into(),
|
||||
worktree: WorktreeInfo {
|
||||
open_workspace_id: None,
|
||||
..worktree.clone()
|
||||
},
|
||||
forced: false,
|
||||
},
|
||||
},
|
||||
EventEnvelope {
|
||||
event: EventKind::WorkspaceClosed,
|
||||
data: EventData::WorkspaceClosed {
|
||||
workspace_id: "w_2".into(),
|
||||
workspace: Some(workspace.clone()),
|
||||
},
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_response_round_trips_with_root_pane() {
|
||||
let response = SuccessResponse {
|
||||
|
||||
@@ -128,6 +128,18 @@ impl ActiveSubscription {
|
||||
event_kind: crate::api::schema::EventKind::WorkspaceFocused,
|
||||
last_sequence: 0,
|
||||
})),
|
||||
Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
||||
event_kind: crate::api::schema::EventKind::WorktreeCreated,
|
||||
last_sequence: 0,
|
||||
})),
|
||||
Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription {
|
||||
event_kind: crate::api::schema::EventKind::WorktreeOpened,
|
||||
last_sequence: 0,
|
||||
})),
|
||||
Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription {
|
||||
event_kind: crate::api::schema::EventKind::WorktreeRemoved,
|
||||
last_sequence: 0,
|
||||
})),
|
||||
Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
||||
event_kind: crate::api::schema::EventKind::TabCreated,
|
||||
last_sequence: 0,
|
||||
|
||||
@@ -1392,6 +1392,7 @@ impl App {
|
||||
"closing this pane would close a worktree group",
|
||||
);
|
||||
}
|
||||
let workspace_snapshot = self.workspace_info(ws_idx);
|
||||
let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id);
|
||||
let should_close_workspace = {
|
||||
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
|
||||
@@ -1412,7 +1413,10 @@ impl App {
|
||||
});
|
||||
self.emit_event(EventEnvelope {
|
||||
event: EventKind::WorkspaceClosed,
|
||||
data: EventData::WorkspaceClosed { workspace_id },
|
||||
data: EventData::WorkspaceClosed {
|
||||
workspace_id,
|
||||
workspace: Some(workspace_snapshot),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self.state.remove_unattached_terminal_ids(terminal_id);
|
||||
|
||||
@@ -151,12 +151,16 @@ impl App {
|
||||
return workspace_not_found(id, &target.workspace_id);
|
||||
}
|
||||
let workspace_id = self.public_workspace_id(index);
|
||||
let workspace = self.workspace_info(index);
|
||||
self.state.selected = index;
|
||||
self.state.close_selected_workspace();
|
||||
self.shutdown_detached_terminal_runtimes();
|
||||
self.emit_event(EventEnvelope {
|
||||
event: EventKind::WorkspaceClosed,
|
||||
data: EventData::WorkspaceClosed { workspace_id },
|
||||
data: EventData::WorkspaceClosed {
|
||||
workspace_id,
|
||||
workspace: Some(workspace),
|
||||
},
|
||||
});
|
||||
|
||||
encode_success(id, ResponseResult::Ok {})
|
||||
@@ -212,4 +216,37 @@ mod tests {
|
||||
assert_eq!(app.state.request_remove_linked_worktree, None);
|
||||
assert!(app.state.workspaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_workspace_close_event_includes_final_worktree_snapshot() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
|
||||
app.state.workspaces = app_with_linked_worktree().state.workspaces;
|
||||
let workspace_id = app.state.workspaces[0].id.clone();
|
||||
|
||||
let response = app.handle_workspace_close(
|
||||
"req".into(),
|
||||
WorkspaceTarget {
|
||||
workspace_id: workspace_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(success.id, "req");
|
||||
let events = event_hub.events_after(0);
|
||||
assert!(events.iter().any(|(_, event)| {
|
||||
matches!(
|
||||
&event.data,
|
||||
EventData::WorkspaceClosed {
|
||||
workspace_id: closed_id,
|
||||
workspace: Some(workspace),
|
||||
} if closed_id == &workspace_id
|
||||
&& workspace
|
||||
.worktree
|
||||
.as_ref()
|
||||
.is_some_and(|worktree| worktree.is_linked_worktree)
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+106
-4
@@ -148,6 +148,13 @@ impl App {
|
||||
let worktree = self
|
||||
.worktree_info_for_checkout(&source, 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(),
|
||||
},
|
||||
});
|
||||
encode_success(
|
||||
id,
|
||||
ResponseResult::WorktreeCreated {
|
||||
@@ -244,6 +251,15 @@ 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(),
|
||||
},
|
||||
});
|
||||
encode_success(
|
||||
id,
|
||||
ResponseResult::WorktreeOpened {
|
||||
@@ -254,7 +270,7 @@ impl App {
|
||||
root_pane: self
|
||||
.root_pane_info(ws_idx, tab_idx)
|
||||
.expect("opened worktree workspace should have an active root pane"),
|
||||
worktree: self.worktree_info_for_entry(&source, entry),
|
||||
worktree,
|
||||
already_open: already_open.is_some(),
|
||||
},
|
||||
)
|
||||
@@ -313,6 +329,8 @@ impl App {
|
||||
&space.checkout_path,
|
||||
params.force,
|
||||
);
|
||||
let workspace_snapshot = self.workspace_info(ws_idx);
|
||||
let worktree = self.worktree_info_for_membership(&space, None);
|
||||
if let Err(err) = crate::worktree::run_worktree_command(&command) {
|
||||
let code = if !params.force && crate::worktree::is_dirty_worktree_remove_error(&err) {
|
||||
"dirty_worktree_requires_force"
|
||||
@@ -337,9 +355,18 @@ impl App {
|
||||
event: EventKind::WorkspaceClosed,
|
||||
data: EventData::WorkspaceClosed {
|
||||
workspace_id: workspace_id.clone(),
|
||||
workspace: Some(workspace_snapshot),
|
||||
},
|
||||
});
|
||||
}
|
||||
self.emit_event(EventEnvelope {
|
||||
event: EventKind::WorktreeRemoved,
|
||||
data: EventData::WorktreeRemoved {
|
||||
workspace_id: workspace_id.clone(),
|
||||
worktree,
|
||||
forced: params.force,
|
||||
},
|
||||
});
|
||||
|
||||
encode_success(
|
||||
id,
|
||||
@@ -747,6 +774,25 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
fn worktree_info_for_membership(
|
||||
&self,
|
||||
membership: &crate::workspace::WorktreeSpaceMembership,
|
||||
open_workspace_id: Option<String>,
|
||||
) -> WorktreeInfo {
|
||||
let branch = crate::workspace::git_branch(&membership.checkout_path);
|
||||
let is_detached = branch.is_none();
|
||||
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,
|
||||
label: membership.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -940,7 +986,14 @@ mod tests {
|
||||
async fn api_worktree_create_opens_workspace_and_marks_membership() {
|
||||
let repo = create_committed_repo("api-worktree-create-repo");
|
||||
let worktree_root = unique_temp_path("api-worktree-create-root");
|
||||
let mut app = app_with_parent(&repo);
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let mut app = test_app_with_event_hub(event_hub.clone());
|
||||
let mut parent = Workspace::test_new("main");
|
||||
parent.identity_cwd = repo.clone();
|
||||
app.state.workspaces = vec![parent];
|
||||
app.state.ensure_test_terminals();
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.worktree_directory = worktree_root.clone();
|
||||
|
||||
let response = app.handle_api_request(Request {
|
||||
@@ -980,6 +1033,17 @@ mod tests {
|
||||
.is_linked_worktree
|
||||
);
|
||||
assert!(workspace.worktree.unwrap().is_linked_worktree);
|
||||
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
|
||||
matches!(
|
||||
&event.data,
|
||||
EventData::WorktreeCreated {
|
||||
workspace: event_workspace,
|
||||
worktree: event_worktree,
|
||||
} if event_workspace.workspace_id == workspace.workspace_id
|
||||
&& event_worktree.branch.as_deref() == Some("worktree/api-create")
|
||||
&& event_worktree.is_linked_worktree
|
||||
)
|
||||
}));
|
||||
|
||||
for (_, runtime) in app.terminal_runtimes.drain() {
|
||||
runtime.shutdown();
|
||||
@@ -1140,7 +1204,11 @@ mod tests {
|
||||
let subdir = checkout.join("nested");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
|
||||
let mut app = app_with_parent(&repo);
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let mut app = test_app_with_event_hub(event_hub.clone());
|
||||
let mut parent = Workspace::test_new("main");
|
||||
parent.identity_cwd = repo.clone();
|
||||
app.state.workspaces = vec![parent];
|
||||
let mut child = Workspace::test_new("child");
|
||||
child.identity_cwd = subdir;
|
||||
app.state.workspaces.push(child);
|
||||
@@ -1175,6 +1243,19 @@ mod tests {
|
||||
.unwrap()
|
||||
.is_linked_worktree
|
||||
);
|
||||
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
|
||||
matches!(
|
||||
&event.data,
|
||||
EventData::WorktreeOpened {
|
||||
workspace: event_workspace,
|
||||
worktree: event_worktree,
|
||||
already_open,
|
||||
} if event_workspace.workspace_id == workspace.workspace_id
|
||||
&& event_worktree.branch.as_deref() == Some("worktree/api-open")
|
||||
&& event_worktree.is_linked_worktree
|
||||
&& *already_open
|
||||
)
|
||||
}));
|
||||
|
||||
let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
|
||||
crate::worktree::run_worktree_command(&remove).unwrap();
|
||||
@@ -1578,7 +1659,28 @@ mod tests {
|
||||
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
|
||||
matches!(
|
||||
&event.data,
|
||||
EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id
|
||||
EventData::WorkspaceClosed {
|
||||
workspace_id,
|
||||
workspace: Some(workspace),
|
||||
} if workspace_id == &child_id
|
||||
&& workspace
|
||||
.worktree
|
||||
.as_ref()
|
||||
.is_some_and(|worktree| worktree.is_linked_worktree)
|
||||
)
|
||||
}));
|
||||
assert!(event_hub.events_after(0).iter().any(|(_, event)| {
|
||||
matches!(
|
||||
&event.data,
|
||||
EventData::WorktreeRemoved {
|
||||
workspace_id,
|
||||
worktree,
|
||||
forced,
|
||||
} if workspace_id == &child_id
|
||||
&& worktree.branch.as_deref() == Some("worktree/api-remove-event")
|
||||
&& worktree.is_linked_worktree
|
||||
&& worktree.open_workspace_id.is_none()
|
||||
&& !forced
|
||||
)
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user