aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins/mod.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/api/plugins/mod.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/api/plugins/mod.rs')
-rw-r--r--src/app/api/plugins/mod.rs196
1 files changed, 104 insertions, 92 deletions
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index ddf3fba6..056315c3 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -6,11 +6,11 @@ mod runtime;
use super::responses::{encode_error, encode_success};
use crate::api::schema::{
- InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams,
- PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction,
- PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo,
- PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams,
- ResponseResult,
+ InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams,
+ PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams,
+ PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams,
+ PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams,
+ PluginUnlinkParams, ResponseResult,
};
use crate::app::App;
pub(super) use manifest::normalize_plugin_id;
@@ -37,7 +37,7 @@ impl App {
}
fn refresh_installed_plugins(&mut self) -> std::io::Result<()> {
- if self.no_session {
+ if !self.policy.persist_plugin_registry {
return Ok(());
}
let entries = crate::persist::plugin_registry::try_load()?;
@@ -49,7 +49,7 @@ impl App {
&mut self,
mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T,
) -> std::io::Result<T> {
- if self.no_session {
+ if !self.policy.persist_plugin_registry {
return Ok(mutation(&mut self.state.installed_plugins));
}
let (result, entries) = crate::persist::plugin_registry::update(|entries| {
@@ -225,6 +225,7 @@ impl App {
pub(crate) fn invoke_plugin_action_from_keybind(
&mut self,
action_id: String,
+ selected_text: Option<String>,
) -> Result<(), String> {
self.refresh_installed_plugins()
.map_err(|err| format!("failed to load plugin registry: {err}"))?;
@@ -241,6 +242,7 @@ impl App {
.map_err(|(_, message)| message)?;
let mut context = self.current_plugin_context("keybinding");
context.invocation_source = Some("keybinding".to_string());
+ context.selected_text = selected_text;
self.start_plugin_command(
&plugin,
Some(action.action_id),
@@ -253,6 +255,80 @@ impl App {
.map_err(|(_, message)| message)
}
+ pub(super) fn handle_pane_link_activate(
+ &mut self,
+ id: String,
+ params: PaneLinkActivateParams,
+ ) -> String {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return encode_error(id, "pane_not_found", "pane not found");
+ };
+ if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) {
+ return encode_error(id, "stale_target", "pane is no longer visible");
+ }
+ let Some(runtime) =
+ self.state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
+ else {
+ return encode_error(id, "pane_not_found", "pane runtime not found");
+ };
+ let current_offset = runtime
+ .scroll_metrics()
+ .map(|metrics| metrics.offset_from_bottom as u64);
+ if params
+ .offset_from_bottom
+ .is_some_and(|expected| current_offset != Some(expected))
+ {
+ return encode_error(
+ id,
+ "stale_content",
+ "pane viewport changed before link activation",
+ );
+ }
+ let content_revision = runtime.content_seq();
+ if content_revision % 2 != 0
+ || params
+ .content_revision
+ .is_some_and(|expected| expected != content_revision)
+ {
+ return encode_error(
+ id,
+ "stale_content",
+ "pane content changed before link activation",
+ );
+ }
+ let url = self.state.url_at_pane_surface_cell(
+ &self.terminal_runtimes,
+ ws_idx,
+ pane_id,
+ params.viewport_row,
+ params.col,
+ );
+ if runtime.content_seq() != content_revision
+ || runtime
+ .scroll_metrics()
+ .map(|metrics| metrics.offset_from_bottom as u64)
+ != current_offset
+ {
+ return encode_error(
+ id,
+ "stale_content",
+ "pane content or viewport changed during link activation",
+ );
+ }
+ let handled = match url.as_deref() {
+ Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) {
+ Ok(handled) => handled,
+ Err(err) => {
+ tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler");
+ false
+ }
+ },
+ None => false,
+ };
+ encode_success(id, ResponseResult::PaneLinkActivated { url, handled })
+ }
+
pub(crate) fn invoke_plugin_link_handler_for_url(
&mut self,
url: &str,
@@ -392,13 +468,8 @@ impl App {
"width and height are only supported when placement is popup",
);
}
- if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal
- {
- return encode_error(
- id,
- "ui_busy",
- "popup panes can only open from the normal workspace view",
- );
+ if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() {
+ return encode_error(id, "ui_busy", "a popup pane is already open");
}
match placement {
PluginPanePlacement::Overlay | PluginPanePlacement::Popup => {
@@ -457,7 +528,7 @@ impl App {
return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
}
self.state.focus_pane_in_workspace(ws_idx, pane_id);
- self.state.settle_terminal_mode_after_focus();
+ self.state.mode = crate::app::Mode::Terminal;
let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else {
return encode_error(id, "plugin_pane_not_found", "plugin pane not found");
};
@@ -716,7 +787,7 @@ mod tests {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
crate::api::EventHub::default(),
@@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"]
let _ = std::fs::remove_dir_all(root);
}
- #[test]
- fn plugin_pane_open_popup_preserves_existing_ui_modes() {
- let mut app = test_app();
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")];
- app.state.ensure_test_terminals();
- app.state.active = Some(0);
- app.state.selected = 0;
- let root_pane = app.state.workspaces[0].tabs[0].root_pane;
- let root = unique_temp_path("plugin-popup-ui-busy");
- write_manifest(&root);
- link_manifest(&mut app, &root);
-
- let open_popup = |app: &mut App, id: &str| {
- app.handle_api_request(Request {
- id: id.into(),
- method: Method::PluginPaneOpen(PluginPaneOpenParams {
- plugin_id: "example.worktree-bootstrap".into(),
- entrypoint: "board".into(),
- placement: Some(PluginPanePlacement::Popup),
- width: None,
- height: None,
- workspace_id: None,
- target_pane_id: None,
- direction: None,
- cwd: None,
- focus: true,
- env: std::collections::HashMap::new(),
- }),
- })
- };
-
- app.state.mode = crate::app::Mode::Settings;
- app.state.settings.original_theme = Some("settings-theme".into());
- let settings_response = open_popup(&mut app, "settings-popup");
- let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap();
- assert_eq!(settings_error["error"]["code"], "ui_busy");
- assert_eq!(app.state.mode, crate::app::Mode::Settings);
- assert_eq!(
- app.state.settings.original_theme.as_deref(),
- Some("settings-theme")
- );
- assert!(app.state.popup_pane.is_none());
-
- let copy_mode = crate::app::state::CopyModeState {
- pane_id: root_pane,
- cursor_row: 2,
- cursor_col: 3,
- entry_offset_from_bottom: 4,
- selection: None,
- search: crate::app::state::CopyModeSearchState::default(),
- };
- app.state.mode = crate::app::Mode::Copy;
- app.state.copy_mode = Some(copy_mode.clone());
- let copy_response = open_popup(&mut app, "copy-popup");
- let copy_error: serde_json::Value = serde_json::from_str(&copy_response).unwrap();
- assert_eq!(copy_error["error"]["code"], "ui_busy");
- assert_eq!(app.state.mode, crate::app::Mode::Copy);
- assert_eq!(app.state.copy_mode, Some(copy_mode));
- assert!(app.state.popup_pane.is_none());
-
- let _ = std::fs::remove_dir_all(root);
- }
-
#[cfg(unix)]
#[tokio::test]
async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() {
@@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
event_hub.clone(),
@@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"]
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
event_hub.clone(),
@@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"]
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
event_hub.clone(),
@@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"]
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
event_hub.clone(),
@@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
write_manifest_content(&root, &manifest);
link_manifest(&mut app, &root);
- let open = app.handle_api_request(Request {
- id: "pane-open-popup".into(),
+ let popup_request = |id: &str| Request {
+ id: id.into(),
method: Method::PluginPaneOpen(PluginPaneOpenParams {
plugin_id: "example.popup".into(),
entrypoint: "board".into(),
@@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
focus: true,
env: std::collections::HashMap::new(),
}),
- });
+ };
+ app.state.mode = crate::app::Mode::Navigate;
+ let open = app.handle_api_request(popup_request("pane-open-popup"));
assert_eq!(response_result(&open), ResponseResult::Ok {});
+ let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate"));
+ let duplicate: crate::api::schema::ErrorResponse =
+ serde_json::from_str(&duplicate).unwrap();
+ assert_eq!(duplicate.error.code, "ui_busy");
assert_eq!(
read_capture_when_ready(&env_capture, || {
app.drain_internal_events();
@@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
.unwrap();
let mut app = test_app();
- app.no_session = false;
+ app.policy.persist_plugin_registry = true;
let workspace = crate::workspace::Workspace::test_new("plugin-refresh");
let pane_id = workspace.tabs[0].root_pane;
app.state.workspaces = vec![workspace];
@@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
make_stale(&mut app);
assert!(app
- .invoke_plugin_action_from_keybind("bootstrap".into())
+ .invoke_plugin_action_from_keybind("bootstrap".into(), None)
.unwrap_err()
.contains("disabled"));
@@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
}
#[tokio::test]
- async fn current_plugin_context_includes_selected_text_for_focused_pane() {
+ async fn current_plugin_context_leaves_client_owned_selection_empty() {
let mut app = test_app();
let workspace = crate::workspace::Workspace::test_new("plugin-selection");
let pane_id = workspace.tabs[0].root_pane;
@@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG
terminal_id,
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"),
);
- app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None));
-
let context = app.current_plugin_context("selection-test");
- assert_eq!(context.selected_text.as_deref(), Some("hello"));
+ assert_eq!(context.selected_text, None);
}
#[cfg(unix)]