From fbd20ad6e488fb3b9efc1612fdffee6e672976c1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 14 Jun 2026 02:43:45 +0300 Subject: feat: add plugin v1 system Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs. --- src/app/api.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 6 deletions(-) (limited to 'src/app/api.rs') diff --git a/src/app/api.rs b/src/app/api.rs index 3a7276c4..6dc48953 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -2,8 +2,11 @@ use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; mod agents; +mod env; mod integrations; +mod layouts; mod panes; +pub(crate) mod plugins; mod responses; mod tabs; mod workspaces; @@ -56,6 +59,37 @@ impl App { return; } + if let AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms, + exit_code, + stdout, + stderr, + error, + } = ev + { + self.state.plugin_commands_in_flight = + self.state.plugin_commands_in_flight.saturating_sub(1); + if let Some(log) = self + .state + .plugin_command_logs + .iter_mut() + .find(|log| log.log_id == log_id) + { + log.finished_unix_ms = Some(finished_unix_ms); + log.exit_code = exit_code; + log.stdout = Some(stdout); + log.stderr = Some(stderr); + log.error = error; + log.status = if log.error.is_none() && log.exit_code == Some(0) { + crate::api::schema::PluginCommandStatus::Succeeded + } else { + crate::api::schema::PluginCommandStatus::Failed + }; + } + return; + } + if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_worktree_add_finished(result); return; @@ -327,7 +361,6 @@ impl App { let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { return false; }; - let public_pane_id = self.public_pane_id(ws_idx, pane_id); let terminal_id = pane_state.attached_terminal_id.clone(); let Some(terminal) = self.state.terminals.get(&terminal_id) else { return false; @@ -339,6 +372,9 @@ impl App { .get(&terminal_id) .map(|runtime| runtime.current_size()) .unwrap_or_else(|| self.state.estimate_pane_size()); + let Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else { + return false; + }; let runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, rows, @@ -347,10 +383,10 @@ impl App { self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + &launch_env, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), - public_pane_id.as_deref(), ) { Ok(runtime) => runtime, Err(err) => { @@ -373,7 +409,7 @@ impl App { true } - pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { + pub(crate) fn emit_pane_state_update(&mut self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; }; @@ -573,7 +609,8 @@ impl App { } } - pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) { + pub(super) fn emit_event(&mut self, event: crate::api::schema::EventEnvelope) { + self.run_plugin_event_hooks(&event); self.event_hub.push(event); } @@ -716,6 +753,15 @@ impl App { Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } + Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => { + return responses::encode_success( + request.id, + ResponseResult::ClientWindowTitle { + changed: false, + reason: crate::api::schema::ClientWindowTitleReason::NoForegroundClient, + }, + ); + } Method::WorkspaceList(_) => return self.handle_workspace_list(request.id), Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target), Method::WorkspaceCreate(params) => { @@ -757,6 +803,11 @@ impl App { Method::PaneMove(params) => return self.handle_pane_move(request.id, params), Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params), Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params), + Method::PaneProcessInfo(params) => { + return self.handle_pane_process_info(request.id, params); + } + Method::LayoutExport(params) => return self.handle_layout_export(request.id, params), + Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params), Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params), Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params), Method::PaneFocusDirection(params) => { @@ -764,6 +815,7 @@ impl App { } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), Method::PaneList(params) => return self.handle_pane_list(request.id, params), + Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), @@ -794,6 +846,39 @@ impl App { Method::IntegrationUninstall(params) => { return self.handle_integration_uninstall(request.id, params); } + Method::PluginLink(params) => { + return self.handle_plugin_link(request.id, params); + } + Method::PluginList(params) => { + return self.handle_plugin_list(request.id, params); + } + Method::PluginUnlink(params) => { + return self.handle_plugin_unlink(request.id, params); + } + Method::PluginEnable(params) => { + return self.handle_plugin_enable(request.id, params); + } + Method::PluginDisable(params) => { + return self.handle_plugin_disable(request.id, params); + } + Method::PluginActionList(params) => { + return self.handle_plugin_action_list(request.id, params); + } + Method::PluginActionInvoke(params) => { + return self.handle_plugin_action_invoke(request.id, params); + } + Method::PluginLogList(params) => { + return self.handle_plugin_log_list(request.id, params); + } + Method::PluginPaneOpen(params) => { + return self.handle_plugin_pane_open(request.id, params); + } + Method::PluginPaneFocus(params) => { + return self.handle_plugin_pane_focus(request.id, params); + } + Method::PluginPaneClose(params) => { + return self.handle_plugin_pane_close(request.id, params); + } _ => { return responses::encode_error( request.id, @@ -1186,6 +1271,75 @@ mod tests { ); } + #[tokio::test] + async fn pane_process_info_returns_response_for_existing_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("process-info")]; + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id, runtime); + let target = app.public_pane_id(0, pane_id).unwrap(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "process_info".into(), + method: crate::api::schema::Method::PaneProcessInfo( + crate::api::schema::PaneProcessInfoParams { + pane_id: Some(target.clone()), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_process_info"); + assert_eq!(response["result"]["process_info"]["pane_id"], target); + } + + #[test] + fn client_window_title_api_reports_no_foreground_client_in_app_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + + let set = app.handle_api_request(crate::api::schema::Request { + id: "title_set".into(), + method: crate::api::schema::Method::ClientWindowTitleSet( + crate::api::schema::ClientWindowTitleSetParams { + title: "plugin review".into(), + }, + ), + }); + let set: serde_json::Value = serde_json::from_str(&set).unwrap(); + assert_eq!(set["result"]["type"], "client_window_title"); + assert_eq!(set["result"]["changed"], false); + assert_eq!(set["result"]["reason"], "no_foreground_client"); + + let clear = app.handle_api_request(crate::api::schema::Request { + id: "title_clear".into(), + method: crate::api::schema::Method::ClientWindowTitleClear( + crate::api::schema::EmptyParams::default(), + ), + }); + let clear: serde_json::Value = serde_json::from_str(&clear).unwrap(); + assert_eq!(clear["result"]["type"], "client_window_title"); + assert_eq!(clear["result"]["reason"], "no_foreground_client"); + } + #[cfg(unix)] #[tokio::test] async fn herdr_toast_context_uses_live_root_runtime_cwd_label() { @@ -1236,10 +1390,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -1328,10 +1482,10 @@ mod tests { 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - None, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); -- cgit v1.2.3