diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/api/plugins/mod.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs | 196 |
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(¶ms.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(©_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)] |
