From 257fb0f50fe2d9c40476c698c5d033ee390c7ce7 Mon Sep 17 00:00:00 2001 From: Sid-Ali <9877335+sidalit@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:00:56 -0500 Subject: [PATCH 1/6] fix(terminal): respect shell environment opt-out (#1903) OPENIDEA_SKIP_SHELL_ENV already stops the IDE importing the login-shell environment; the integrated terminal now also skips its implicit login argument (-l) when it is set, so login profiles don't overwrite the inherited PATH. Explicit shell arguments still win; default behaviour is unchanged. Co-authored-by: sidalit <98773335+sidalit@users.noreply.github.com> --- crates/ide-terminal/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index c53426a54..26d7fe3c9 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -558,13 +558,15 @@ pub fn available_shells() -> Vec { /// argument-less) POSIX shell is started as a login shell, like Terminal.app, /// the Git Bash shortcut and IDEA: login start-up is what loads the completions /// and prompts users expect (`/etc/profile`, Git for Windows' git-completion). +/// `OPENIDEA_SKIP_SHELL_ENV` disables the implicit login argument; explicit +/// arguments still take precedence, and interactive startup files still run. pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell); if !args.is_empty() { return (shell, args.to_vec()); } - let login = - matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh"); + let login = std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_none() + && matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh"); let args = if login { vec!["-l".to_owned()] } else { Vec::new() }; (shell, args) } From 429b8f0efef3c17b6a2b549b511893f1a6da5f2a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:08:25 +0800 Subject: [PATCH 2/6] fix(window): show the window buttons in full screen on macOS (#2015) Native full screen moves a window's buttons into the bar revealed with the menu bar, and a project tab shown in its group's full-screen Space has none, so the title bar's slot for them stayed empty. The title bar now draws them there while the window fills the screen: close closes that project's tab, the green button leaves full screen, and minimize is disabled. --- crates/ide-core/src/event.rs | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 14 +++ src-tauri/src/macos.rs | 1 + src-tauri/src/titlebar.rs | 18 ++-- src/bindings.ts | 9 +- src/platform/locales/en.json | 1 + src/platform/locales/zh.json | 1 + src/styles/tokens.css | 10 +++ src/ui/icons.tsx | 3 + src/ui/openidea/general/macWindowClose.svg | 4 + .../general/macWindowExitFullScreen.svg | 5 ++ src/ui/openidea/general/macWindowMinimize.svg | 4 + src/workbench/MacWindowButtons.tsx | 87 +++++++++++++++++++ src/workbench/TitleBar.module.css | 49 +++++++++++ src/workbench/TitleBar.tsx | 3 + 16 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 src/ui/openidea/general/macWindowClose.svg create mode 100644 src/ui/openidea/general/macWindowExitFullScreen.svg create mode 100644 src/ui/openidea/general/macWindowMinimize.svg create mode 100644 src/workbench/MacWindowButtons.tsx diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 298547f89..a80657afb 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -15,6 +15,8 @@ use std::sync::Arc; pub enum IdeEvent { /// Project window membership or selection changed. WindowGroupsChanged, + /// A window entered or left full screen, itself or as a member of a full-screen window group. + WindowFullscreenChanged, ProjectTrustChanged { root: crate::IpcPath, trusted: bool, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 15bb3d3d4..335e9f52d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -431,6 +431,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_toggle_fullscreen, window::window_flash_taskbar, window::window_set_titlebar_height, + window::window_is_fullscreen, window_drag::window_start_drag, window::window_take_launch, window::window_close_context, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index fb1534d85..a93262ec3 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -193,6 +193,20 @@ pub fn window_open_projects( .collect()) } +/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window +/// group's full-screen Space. +#[tauri::command] +#[specta::specta] +pub fn window_is_fullscreen(window: WebviewWindow) -> bool { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + crate::window_groups::in_fullscreen(window.app_handle(), &window) + } + #[cfg(not(target_os = "macos"))] + window.is_fullscreen().unwrap_or(false) +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct CloseContext { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7b6a63add..752fe29a9 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -135,6 +135,7 @@ pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWin if let Some(frame) = frame { native.setFrame_display(frame, true); } + target.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }) } diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index bf32bbac3..eabe726ec 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -12,11 +12,12 @@ use objc2::{ runtime::ProtocolObject, }; use objc2_app_kit::{ - NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle, - NSWindowWillEnterFullScreenNotification, + NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification, + NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification, }; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; +use tauri::Manager; /// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. const COMPACT_MAX_HEIGHT: f64 = 45.0; @@ -55,7 +56,7 @@ impl Drop for Observer { struct Tracked { state: Rc, - _observers: [Observer; 2], + _observers: [Observer; 3], } thread_local! { @@ -71,7 +72,7 @@ fn apply(window: &NSWindow, state: &Fullscreen) { } } -fn track(window: &NSWindow) -> Tracked { +fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked { let state = Rc::new(Fullscreen::default()); if window.styleMask().contains(NSWindowStyleMask::FullScreen) { state.will_enter(); @@ -79,11 +80,14 @@ fn track(window: &NSWindow) -> Tracked { let observe = |name, change: fn(&Fullscreen)| { let weak = Weak::new(window); let state = state.clone(); + let app = app.clone(); let block = block2::RcBlock::new(move |_| { change(&state); if let Some(window) = weak.load() { apply(&window, &state); } + // The web title bar shows the window buttons itself in full screen. + app.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }); let observer = unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( @@ -98,6 +102,7 @@ fn track(window: &NSWindow) -> Tracked { let observers = unsafe { [ observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter), + observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter), observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit), ] }; @@ -106,12 +111,13 @@ fn track(window: &NSWindow) -> Tracked { pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { let label = window.label().to_owned(); + let app = window.app_handle().clone(); // with_webview runs on the AppKit thread. window.with_webview(move |webview| unsafe { let Some(mtm) = MainThreadMarker::new() else { return }; let native = &*webview.ns_window().cast::(); - let state = - WINDOWS.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native)).state.clone()); + let state = WINDOWS + .with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone()); if native.toolbar().is_none() { let toolbar = NSToolbar::new(mtm); native.setToolbar(Some(&toolbar)); diff --git a/src/bindings.ts b/src/bindings.ts index 4abe6b450..a763377ba 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -903,6 +903,11 @@ export const commands = { windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), + /** + * Whether this window fills the screen: in native full screen, or (macOS) shown in its window + * group's full-screen Space. + */ + windowIsFullscreen: () => __TAURI_INVOKE("window_is_fullscreen"), /** * Starts dragging `window` while the primary mouse button is still down; returns whether a drag began. * @@ -4479,7 +4484,9 @@ export type IdeError = { export type IdeEvent = /** Project window membership or selection changed. */ -{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | +{ type: "windowGroupsChanged" } | +/** A window entered or left full screen, itself or as a member of a full-screen window group. */ +{ type: "windowFullscreenChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | /** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */ diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 493b0cf13..0dce59ac9 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -196,6 +196,7 @@ "window.caption.minimize": "Minimize", "window.caption.maximize": "Maximize", "window.caption.restore": "Restore Down", + "window.caption.exitFullScreen": "Exit Full Screen", "window.caption.close": "Close", "menu.help.keymapReference": "Keymap Reference", "menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 9db8c6437..d93abbf75 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -196,6 +196,7 @@ "window.caption.minimize": "最小化", "window.caption.maximize": "最大化", "window.caption.restore": "向下还原", + "window.caption.exitFullScreen": "退出全屏", "window.caption.close": "关闭", "menu.help.keymapReference": "快捷键参考", "menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 1476e486b..ea1a96da6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -436,6 +436,16 @@ html { --fg-caption-close-hover: #ffffff; } +/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */ +:root { + --mac-window-close: #ff5f57; + --mac-window-minimize: #febc2e; + --mac-window-zoom: #28c840; + --mac-window-glyph: rgb(0 0 0 / 55%); + --mac-window-edge: rgb(0 0 0 / 12%); + --mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent); +} + /* * Islands Light. Every colour token of :root is redefined here. Values are * PyCharm's own; its secondary greys, comment / metadata colours, line numbers diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 8368918a5..f857b06df 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -248,6 +248,9 @@ const ICONS = { "window-maximize": "openidea/general/windowMaximize", "window-restore": "openidea/general/windowRestore", "window-close": "openidea/general/windowClose", + "mac-window-close": "openidea/general/macWindowClose", + "mac-window-minimize": "openidea/general/macWindowMinimize", + "mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen", hide: "general/hide", pin: "general/pin", eye: "general/show", diff --git a/src/ui/openidea/general/macWindowClose.svg b/src/ui/openidea/general/macWindowClose.svg new file mode 100644 index 000000000..dcf91cfbd --- /dev/null +++ b/src/ui/openidea/general/macWindowClose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/general/macWindowExitFullScreen.svg b/src/ui/openidea/general/macWindowExitFullScreen.svg new file mode 100644 index 000000000..4c0b528a9 --- /dev/null +++ b/src/ui/openidea/general/macWindowExitFullScreen.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/openidea/general/macWindowMinimize.svg b/src/ui/openidea/general/macWindowMinimize.svg new file mode 100644 index 000000000..ab44ef431 --- /dev/null +++ b/src/ui/openidea/general/macWindowMinimize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/workbench/MacWindowButtons.tsx b/src/workbench/MacWindowButtons.tsx new file mode 100644 index 000000000..fe4113345 --- /dev/null +++ b/src/workbench/MacWindowButtons.tsx @@ -0,0 +1,87 @@ +/** + * macOS window buttons in the title bar's slot while the window fills the screen. Native full + * screen moves the system's buttons into the bar revealed with the menu bar, and a project tab + * shown in its group's full-screen Space has none, so the slot kept for them would be empty. + * Close closes this project's tab, the green button leaves full screen, minimize is disabled. + * Like the system's, they take no keyboard focus. + */ +import { useEffect, useState } from "react"; +import clsx from "clsx"; +import { t } from "@/platform/i18n"; +import { commands, onIdeEvent } from "@/platform/ipc"; +import { Icon } from "@/ui"; +import styles from "./TitleBar.module.css"; + +type AppWindow = import("@tauri-apps/api/window").Window; + +function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { + const [fullscreen, setFullscreen] = useState(false); + const [active, setActive] = useState(() => document.hasFocus()); + useEffect(() => { + let disposed = false; + const offs: (() => void)[] = []; + const sync = () => void commands.windowIsFullscreen().then((on) => !disposed && setFullscreen(on)); + sync(); + offs.push(onIdeEvent("windowFullscreenChanged", sync)); + void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { + const w: AppWindow = getCurrentWindow(); + const listeners = await Promise.all([ + w.onResized(sync), + w.onFocusChanged(({ payload }) => { + setActive(payload); + sync(); + }), + ]); + if (disposed) listeners.forEach((off) => off()); + else offs.push(...listeners); + }); + return () => { + disposed = true; + offs.forEach((off) => off()); + }; + }, []); + return { fullscreen, active }; +} + +function closeWindow() { + void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close()); +} + +export function MacWindowButtons() { + const { fullscreen, active } = useFullscreenWindow(); + if (!fullscreen) return null; + return ( +
e.preventDefault()} + > + + + +
+ ); +} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index 2c4443521..de70f2b38 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -195,3 +195,52 @@ background: var(--caption-close-pressed); color: var(--fg-caption-close-hover); } + +/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */ +.macButtons { + position: fixed; + top: 0; + left: 0; + height: var(--h-titlebar); + display: flex; + align-items: center; + gap: 8px; + padding-left: 12px; + zoom: calc(1 / var(--titlebar-zoom, 1)); + z-index: 1; +} +.macButton { + width: 12px; + height: 12px; + padding: 0; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--mac-window-glyph); + box-shadow: inset 0 0 0 0.5px var(--mac-window-edge); +} +.macButton > * { + opacity: 0; +} +.macButtons:hover .macButton:not(:disabled) > * { + opacity: 1; +} +.macClose { + background: var(--mac-window-close); +} +.macMinimize { + background: var(--mac-window-minimize); +} +.macZoom { + background: var(--mac-window-zoom); +} +.macButton:active:not(:disabled) { + filter: brightness(0.8); +} +/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */ +.macButton:disabled, +.macButtonsInactive:not(:hover) .macButton { + background: var(--mac-window-inactive); +} diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 806068a97..aeedf51c0 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -21,6 +21,7 @@ import { widgetTitle, } from "./mainToolbar"; import { WindowControls } from "./WindowControls"; +import { MacWindowButtons } from "./MacWindowButtons"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch"; @@ -301,6 +302,7 @@ export function TitleBar({ if (bare) return (
+ {overlay && } {frameless && ( <> @@ -323,6 +325,7 @@ export function TitleBar({ showContextMenu(e, menu); }} > + {overlay && } {frameless && !minimal && } {title && ( From ebc1c2183662cecdd468fafbd45394fd5b4d870b Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Sat, 7 Nov 2026 20:12:49 +0500 Subject: [PATCH 3/6] fix(editor): complete dependency versions in go.mod (#1871) go.mod require entries (single-line and block) now get version completion. gopls doesn't provide module-file completions, so the existing dependency-version completion queries `go list -m -versions` with the workspace member's Go SDK and proxy settings, from a temp directory with GOWORK=off so a half-edited go.mod doesn't block it. The query has a 10 s deadline and honours cancellation; failures return no versions and aren't cached. The cache key for Go hashes root and environment so proxy credentials are never stored in clear. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- crates/ide-host/src/ops/debug.rs | 2 +- crates/ide-host/src/ops/dependencies.rs | 93 +++++++++++++- .../ide-host/src/ops/dependencies/go_tests.rs | 107 ++++++++++++++++ crates/ide-sdk/src/go.rs | 2 +- .../ide-workspace/src/dependencies/cache.rs | 8 +- crates/ide-workspace/src/dependencies/go.rs | 118 ++++++++++++++++++ crates/ide-workspace/src/dependencies/mod.rs | 7 ++ .../dependencies/dependencies.test.ts | 40 +++++- src/features/dependencies/index.ts | 33 +++-- 9 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 crates/ide-host/src/ops/dependencies/go_tests.rs create mode 100644 crates/ide-workspace/src/dependencies/go.rs diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 8cf3a44d1..737db0e2f 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide let settings = settings.clone(); config = host .blocking(move || { - ide_sdk::go::debug_environment(&root, &settings, &mut config.env); + ide_sdk::go::configure_environment(&root, &settings, &mut config.env); ide_sdk::go::apply_to_debug( &mut config.env, &mut config.extra_json, diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 967095879..1e57b5576 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -1,7 +1,11 @@ //! Repository version lookups for manifest inspections and completion. use crate::{Host, rpc::Dispatcher}; use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; -use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go}; +use std::collections::BTreeMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::Command; struct CratesIo; impl DependencyVersionSource for CratesIo { @@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo { } } +struct GoModules { + root: PathBuf, + env: BTreeMap, +} + +impl DependencyVersionSource for GoModules { + fn id(&self) -> &'static str { + "go-modules" + } + + fn cache_key(&self, package: &str) -> Option { + let mut hash = DefaultHasher::new(); + (&self.root, &self.env, package).hash(&mut hash); + Some(format!("{}-{:016x}", self.id(), hash.finish())) + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let search = + self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path); + let go = ide_core::tool_path::find_command("go", &search, &self.root) + .ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?; + // A partially edited go.mod is often invalid. Query outside it while + // keeping the SDK and proxy/private module configuration. + let dir = tempfile::tempdir()?; + let mut cmd = Command::new(go); + cmd.current_dir(dir.path()) + .env("PATH", search) + .envs(&self.env) + .env("GO111MODULE", "on") + .env("GOFLAGS", "") + .env("GOWORK", "off") + .env("GIT_TERMINAL_PROMPT", "0") + .args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]); + let query = cancel.child_token(); + // Fetches run on Host::blocking's Tokio pool, which keeps a runtime current. + let deadline = tokio::spawn({ + let query = query.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + query.cancel(); + } + }); + let output = ide_tools::run_streamed(cmd, &query, &|_| {}); + deadline.abort(); + Ok(go::listed_versions(&output?)) + } +} + +#[cfg(all(test, unix))] +#[path = "dependencies/go_tests.rs"] +mod go_tests; + +fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult { + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { + workspace + .projects + .into_iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()) + }); + let settings = match &member { + Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(), + None => project.settings.effective(), + }; + let root = member.unwrap_or_else(|| project.root.clone()); + let mut env = BTreeMap::new(); + ide_sdk::go::configure_environment(&root, &settings, &mut env); + Ok(GoModules { root, env }) +} + /// `offset` limits a completion request to its version string; inspections omit it. pub async fn dependency_versions( host: &Host, @@ -31,11 +105,24 @@ pub async fn dependency_versions( ) -> IdeResult> { let project = host.project(window)?; let cache = host.dependency_versions.clone(); - let cancel = host.begin_request(request); + let config_dir = host.config_dir.clone(); + let cancel = host.begin_project_read_request(window, request); let result = host .blocking(move || { let snap = project.documents.snapshot(doc)?; - if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + if snap.large || snap.read_only { + return Ok(Vec::new()); + } + if snap.path.file_name().is_some_and(|name| name == "go.mod") { + let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else { + return Ok(Vec::new()); + }; + project.require_trust()?; + let source = go_source(&project, &config_dir, snap.path.as_ref())?; + let versions = cache.versions(&source, &dependency.package, &cancel)?; + return Ok(vec![DependencyVersions { dependency, versions, update: None }]); + } + if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") { return Ok(Vec::new()); } let mut out = Vec::new(); diff --git a/crates/ide-host/src/ops/dependencies/go_tests.rs b/crates/ide-host/src/ops/dependencies/go_tests.rs new file mode 100644 index 000000000..cdec57e0e --- /dev/null +++ b/crates/ide-host/src/ops/dependencies/go_tests.rs @@ -0,0 +1,107 @@ +use super::*; +use ide_workspace::dependencies::VersionCache; +use std::os::unix::fs::PermissionsExt; +use std::time::{Duration, Instant}; + +fn source(dir: &Path, script: &str) -> GoModules { + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let go = bin.join("go"); + std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap(); + GoModules { + root: dir.to_owned(), + env: BTreeMap::from([ + ("PATH".into(), bin.to_string_lossy().into_owned()), + ("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()), + ("GOPROXY".into(), "https://user:secret@proxy.example".into()), + ("GOPRIVATE".into(), "private.example/token".into()), + ("GOWORK".into(), "broken.work".into()), + ("GOFLAGS".into(), "-mod=vendor".into()), + ]), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() { + let dir = tempfile::tempdir().unwrap(); + let source = source( + dir.path(), + r#"set -eu +[ "$PWD" != "$PROJECT_ROOT" ] +[ "$GOPROXY" = 'https://user:secret@proxy.example' ] +[ "$GOPRIVATE" = 'private.example/token' ] +[ "$GOWORK" = off ] +[ "$GOFLAGS" = '' ] +[ "$GO111MODULE" = on ] +[ "$GIT_TERMINAL_PROMPT" = 0 ] +[ "$1 $2 $3 $4" = 'list -m -versions -f' ] +[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ] +[ "$6 $7" = '-- example.com/Org/Lib/v2' ] +printf 'v2.9.0\nv2.10.0\n'"#, + ); + let key = source.cache_key("example.com/Org/Lib/v2").unwrap(); + assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap()); + let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() }; + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + other.root = source.root.clone(); + other.env.insert("GOPROXY".into(), "https://other.example".into()); + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(versions, ["v2.10.0", "v2.9.0"]); + let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect(); + assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter"); + assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json")); + let cached = std::fs::read_to_string(entries[0].path()).unwrap(); + for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] { + assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_go_queries_are_silent_and_not_persisted() { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1"); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert!(versions.is_empty()); + assert!(!disk.exists()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() { + for cancel_parent in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "exec /bin/sleep 60"); + let cancel = CancellationToken::new(); + let query = cancel.clone(); + let started = Instant::now(); + let work = tokio::task::spawn_blocking(move || { + VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query) + }); + if cancel_parent { + tokio::time::sleep(Duration::from_millis(150)).await; + cancel.cancel(); + assert!(work.await.unwrap().is_err()); + assert!(started.elapsed() < Duration::from_secs(3)); + } else { + assert!(work.await.unwrap().unwrap().is_empty()); + assert!(started.elapsed() >= Duration::from_secs(10)); + assert!(started.elapsed() < Duration::from_secs(15)); + assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller"); + } + } +} diff --git a/crates/ide-sdk/src/go.rs b/crates/ide-sdk/src/go.rs index 3a30f772c..d6a815d3e 100644 --- a/crates/ide-sdk/src/go.rs +++ b/crates/ide-sdk/src/go.rs @@ -228,7 +228,7 @@ mod command_tests { } } -pub fn debug_environment( +pub fn configure_environment( root: &std::path::Path, settings: &ide_core::settings::Settings, env: &mut std::collections::BTreeMap, diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs index 7be33fe7e..fb448e230 100644 --- a/crates/ide-workspace/src/dependencies/cache.rs +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -39,11 +39,11 @@ impl VersionCache { package: &str, cancel: &CancellationToken, ) -> IdeResult> { - let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); - if !safe(source.id()) || !safe(package) || package.len() > 64 { + let Some(key) = source.cache_key(package).filter(|key| { + !key.is_empty() && key.len() <= 128 && key.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) + }) else { return Ok(Vec::new()); - } - let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + }; let slot = self.entries.lock().entry(key.clone()).or_default().clone(); let mut cached = loop { if cancel.is_cancelled() { diff --git a/crates/ide-workspace/src/dependencies/go.rs b/crates/ide-workspace/src/dependencies/go.rs new file mode 100644 index 000000000..c93e797a6 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/go.rs @@ -0,0 +1,118 @@ +use super::ManifestDependency; + +fn module_path(path: &str) -> bool { + let Some(domain) = path.split('/').next() else { return false }; + path.len() <= 512 + && domain.contains('.') + && !domain.starts_with(['.', '-']) + && path.split('/').all(|part| { + !part.is_empty() + && !part.ends_with('.') + && part.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)) + }) +} + +/// Finds the version at a UTF-16 offset, including incomplete require lines. +pub fn dependency_at(text: &str, offset: u32) -> Option { + let mut block = None; + let mut start = 0; + for line in text.split_inclusive('\n') { + let line_len = line.encode_utf16().count() as u32; + let code = line.split("//").next()?.trim_end_matches(['\r', '\n']); + let trimmed = code.trim(); + if offset < start + line_len || offset == start + line_len && !line.ends_with('\n') { + let body = match block { + Some("require") => code.trim_start(), + None => code.trim_start().strip_prefix("require")?.strip_prefix([' ', '\t'])?.trim_start(), + _ => return None, + }; + let (path, rest) = body.split_once([' ', '\t'])?; + let package = path.trim_matches('"'); + if !module_path(package) { + return None; + } + let version = rest.trim_start(); + let token = version.split_ascii_whitespace().next().unwrap_or_default(); + let from = start + code[..code.len() - version.len()].encode_utf16().count() as u32; + let (requirement, from) = match token.strip_prefix('"') { + Some(quoted) => (quoted.strip_suffix('"').unwrap_or(quoted), from + 1), + None => (token, from), + }; + let to = from + requirement.encode_utf16().count() as u32; + return (from <= offset && offset <= to).then(|| ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from, + to, + }); + } + if trimmed == ")" { + block = None; + } else if block.is_none() { + block = trimmed.strip_suffix('(').map(str::trim); + } + start += line_len; + } + None +} + +/// Go lists versions in ascending semantic order. Ignore tool output unrelated to versions. +pub fn listed_versions(output: &str) -> Vec { + output + .lines() + .rev() + .map(str::trim) + .filter(|line| line.strip_prefix('v').is_some_and(|version| semver::Version::parse(version).is_ok())) + .map(str::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completes_blank_partial_and_quoted_require_versions() { + for (text, package, version) in [ + ("module example.com/m\nrequire golang.org/x/text |", "golang.org/x/text", ""), + ( + "// 中文 😀\r\nrequire (\r\n\tgithub.com/Org/Lib/v2 v2.|1.0 // indirect\r\n)\r\n", + "github.com/Org/Lib/v2", + "v2.1.0", + ), + ("require \"rsc.io/quote\" \"v1.|2.0\"", "rsc.io/quote", "v1.2.0"), + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + let text = text.replace('|', ""); + let item = dependency_at(&text, offset).unwrap(); + assert_eq!(item.package, package); + assert_eq!(item.requirement, version); + let units: Vec<_> = text.encode_utf16().collect(); + assert_eq!(String::from_utf16(&units[item.from as usize..item.to as usize]).unwrap(), version); + } + } + + #[test] + fn ignores_comments_other_directives_and_non_module_arguments() { + for text in [ + "// require rsc.io/quote v|", + "require rsc.io/quote v1.2.0 // in|direct", + "require rsc.io/qu|ote v1.2.0", + "replace (\n\trsc.io/quote v|\n)", + "require ../quote v|", + "require -flag.example v|", + "require rsc.io/quote@latest v|", + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + assert!(dependency_at(&text.replace('|', ""), offset).is_none(), "{text}"); + } + } + + #[test] + fn keeps_go_version_spelling_and_prefers_newer_versions() { + assert_eq!( + listed_versions("go: notice\nv1.9.0\nv1.10.0-rc.1\nv2.0.0+incompatible\n"), + ["v2.0.0+incompatible", "v1.10.0-rc.1", "v1.9.0"] + ); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs index eef3a69a3..9b802782a 100644 --- a/crates/ide-workspace/src/dependencies/mod.rs +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -1,6 +1,7 @@ //! Manifest dependencies and repository versions, independent of editor and transport. mod cache; pub mod cargo; +pub mod go; pub use cache::VersionCache; use ide_core::{CancellationToken, IdeResult}; @@ -11,6 +12,12 @@ use specta::Type; pub trait DependencyVersionSource: Send + Sync { /// A filesystem-safe, stable repository identifier. fn id(&self) -> &'static str; + /// Override for case-sensitive module paths or project-specific repositories. + fn cache_key(&self, package: &str) -> Option { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + let id = self.id(); + (safe(id) && safe(package) && package.len() <= 64).then(|| format!("{id}-{}", package.to_ascii_lowercase())) + } /// Non-yanked versions in descending semantic order. fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; } diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts index e3322c592..53cae7e8d 100644 --- a/src/features/dependencies/dependencies.test.ts +++ b/src/features/dependencies/dependencies.test.ts @@ -46,14 +46,14 @@ afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); -function editor(path = "/p/Cargo.toml", readOnly = false) { +function editor(path = "/p/Cargo.toml", readOnly = false, source = text) { const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; const view = new EditorView({ parent: document.body, state: EditorState.create({ - doc: text, - selection: { anchor: from }, + doc: source, + selection: { anchor: Math.min(from, source.length) }, extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], }), }); @@ -208,3 +208,37 @@ describe("Cargo dependency versions", () => { expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); }); }); + +describe("Go module dependency versions", () => { + it("requests versions at the caret and replaces the entire version without running inspections", async () => { + const source = "module example.com/app\n\nrequire (\n\tgolang.org/x/text v0.1.0 // indirect\n)\n"; + const from = source.indexOf("v0.1.0"); + const to = from + "v0.1.0".length; + const view = editor("/p/go.mod", false, source); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [ + { + dependency: { package: "golang.org/x/text", requirement: "v0.1.0", from, to }, + versions: ["v1.2.0", "v0.12.0", "v0.1.0"], + update: null, + }, + ], + }); + const complete = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + expect(await complete(new CompletionContext(view.state, from + 3, false, view))).toMatchObject({ + from, + to, + options: [ + { label: "v0.12.0", apply: "v0.12.0" }, + { label: "v0.1.0", apply: "v0.1.0" }, + ], + }); + expect(dependencyVersions).toHaveBeenCalledExactlyOnceWith(7, from + 3, expect.any(Number)); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts index 3a47f969f..c90df0743 100644 --- a/src/features/dependencies/index.ts +++ b/src/features/dependencies/index.ts @@ -168,12 +168,25 @@ function extension(doc: EditorDocContext): Extension { }, }; }); + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + versionCompletion(doc), + ]; +} + +function versionCompletion(doc: EditorDocContext): Extension { const completion = async (ctx: CompletionContext): Promise => { if (!isTauri()) return null; const id = nextRequestId(); const text = ctx.state.doc; const root = useProject.getState().project?.root; - ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + ctx.addEventListener("abort", () => void commands.cancelRequest(id), { onDocChange: true }); try { // The completion context is immutable; only use it while its document is current. const view = ctx.view; @@ -188,21 +201,17 @@ function extension(doc: EditorDocContext): Extension { return null; } }; - return [ - versionsField, - plugin, - intentionProviders.of((view) => { - const selection = view.state.selection.main; - return dependencyFixes(view, selection.from, selection.to); - }), - quickFixProviders.of(dependencyFixes), - EditorState.languageData.of(() => [{ openideaCompletion: completion }]), - ]; + return EditorState.languageData.of(() => [{ openideaCompletion: completion }]); } export function activate(): () => void { return registerEditorContribution({ id: "dependency-versions", - extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + extensions: (doc) => { + if (doc.virtual || doc.readOnly) return []; + if (/(?:^|\/)Cargo\.toml$/.test(doc.path)) return extension(doc); + if (/(?:^|\/)go\.mod$/.test(doc.path)) return versionCompletion(doc); + return []; + }, }); } From 8509ca671f57edab9dcf7873c6cf25ffdbdfab40 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:22:03 +0800 Subject: [PATCH 4/6] fix(agents): sign-in hint names the CLI the IDE actually runs (#2018) Fixes #1913. The sign-in hint named the bare command (`cursor-agent login`), but the IDE launches agents through its own resolution (registry installs under the config dir, the login shell's PATH, PATHEXT shims), so the named command could be missing from the terminal's PATH. The agent spec is now resolved once before connecting, and when that executable isn't what a PATH lookup of the bare name finds, every built-in hint names the resolved path, quoted for PowerShell or POSIX shells. Hints for separate login tools (claude-acp, codex-acp, vibe) and custom agents are unchanged; the hint is translated through templates. --- crates/ide-agent/src/lib.rs | 16 +-- crates/ide-agent/src/provider.rs | 135 +++++++++++++++++++++++++- crates/ide-agent/src/tests.rs | 49 ++++++++++ crates/ide-jsonrpc/src/lib.rs | 2 +- src/features/agents/BuiltinAgents.tsx | 4 +- src/features/agents/SessionEditor.tsx | 7 +- src/features/agents/loginHint.test.ts | 26 +++++ src/features/agents/loginHint.ts | 9 ++ src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + 10 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 src/features/agents/loginHint.test.ts create mode 100644 src/features/agents/loginHint.ts diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a31d09a7d..eb1259220 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -221,6 +221,7 @@ struct TaskState { /// A running agent and its session. #[derive(Clone)] struct Live { + sign_in_message: String, peer: acp::Conn, session: String, capabilities: AgentCapabilities, @@ -1301,11 +1302,7 @@ impl Task { match e { // Signed out, or the account was refused (Claude Code: "API Error: 403"). RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => { - let provider = self.state.lock().task.provider.clone(); - Failure::from(match self.shared.spec(&provider) { - Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint), - None => message, - }) + Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message)) } e => Failure::from(self.describe(e)), } @@ -1323,6 +1320,11 @@ impl Task { (s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone()) }; let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; + let launch_cwd = cwd.clone(); + let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd)) + .await + .map_err(|e| e.to_string())?; + let sign_in_message = spec.sign_in_message(); self.stderr.lock().clear(); self.config_sent.lock().clear(); let weak = Arc::downgrade(self); @@ -1368,7 +1370,7 @@ impl Task { .await .map_err(|e| match e { RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => { - format!("{} needs you to sign in. {}", spec.name, spec.login_hint) + sign_in_message.clone() } e => self.describe(e), })?; @@ -1440,7 +1442,7 @@ impl Task { } self.persist(); self.emit_task(); - let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load }; + let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load }; *self.live.lock() = Some(live.clone()); Ok(live) } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 410bb89eb..14d388d58 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -81,6 +81,64 @@ fn builtins() -> Vec { .collect() } +impl ProviderSpec { + pub(crate) fn login_hint(&self, program: &Path) -> String { + if !program.is_absolute() { + return self.login_hint.clone(); + } + let search = ide_core::process::host_var_os("PATH").unwrap_or_default(); + let builtin = builtin_spec(&self.id); + let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search)); + render_login_hint(self, program, on_path.as_deref(), cfg!(windows)) + } + + pub(crate) fn sign_in_message(&self) -> String { + let name = &self.name; + let hint = self.login_hint(Path::new(&self.command)); + format!("{name} needs you to sign in. {hint}") + } + + /// Resolve once before connecting, so authentication guidance names that same install. + pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self { + if self.driver != Driver::Acp { + self.command = crate::native::newest_install(&self.command); + } + let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env); + self.command = + if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program } + .to_string_lossy() + .into_owned(); + self + } +} + +fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String { + let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() }; + let prefix = format!("Run `{}", builtin.command); + let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else { + return spec.login_hint.clone(); + }; + let same_path = |a: &Path, b: &Path| { + a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) + }; + if on_path.is_some_and(|path| same_path(path, program)) { + return spec.login_hint.clone(); + } + let path = program.to_string_lossy(); + let quoted = if windows { + format!("& '{}'", path.replace('\'', "''")) + } else { + format!("'{}'", path.replace('\'', "'\\''")) + }; + let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command"); + let command = format!("{quoted}{args}"); + match suffix { + " in a terminal." => format!("Run `{command}` in a terminal."), + " in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."), + _ => spec.login_hint.clone(), + } +} + /// Whether `id` names a built-in agent that needs no registry install (native drivers /// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and /// updated from the ACP Registry into `/agents/.toml`. @@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { args: spec.args.clone(), default_program: builtin.as_ref().map(|b| b.command.clone()), default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(), - login_hint: Some(spec.login_hint.clone()), + login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))), command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command), command_editable: reason.is_none(), command_read_only_reason: reason, @@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool { mod tests { use super::*; + #[test] + fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() { + for spec in builtins() { + let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe"); + for windows in [false, true] { + let program = + if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") }; + let hint = render_login_hint(&spec, program, None, windows); + if separate { + assert_eq!(hint, spec.login_hint, "{}", spec.id); + } else { + let prefix = + if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" }; + assert!(hint.starts_with(prefix), "{}: {hint}", spec.id); + assert_eq!( + render_login_hint(&spec, program, Some(program), windows), + spec.login_hint, + "a PATH-resolved command stays bare: {}", + spec.id + ); + assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint); + } + } + } + } + + #[test] + fn login_commands_quote_shell_metacharacters_and_keep_arguments() { + let cursor = builtin_spec("cursor").unwrap(); + assert_eq!( + render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false), + "Run `'/agent'\\''s tools/$cli' login` in a terminal." + ); + assert_eq!( + render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true), + r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal." + ); + let opencode = builtin_spec("opencode").unwrap(); + assert_eq!( + render_login_hint(&opencode, Path::new("/registry/opencode"), None, false), + "Run `'/registry/opencode' auth login` in a terminal." + ); + let mut custom = cursor; + custom.id = "custom".into(); + custom.login_hint = "Sign in with the agent's own command line first.".into(); + assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint); + } + + #[test] + fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() { + let dir = tempfile::tempdir().unwrap(); + let mut cursor = builtin_spec("cursor").unwrap(); + let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" }; + let program = dir.path().join(command); + std::fs::write(&program, "").unwrap(); + cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned())); + let resolved = cursor.clone().resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), program); + assert!(resolved.sign_in_message().contains(&*program.to_string_lossy())); + cursor.command = format!("tools/{command}"); + let resolved = cursor.resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command)); + } + + #[cfg(unix)] + #[test] + fn registry_login_hint_names_the_installed_executable() { + let mut spec = builtin_spec("cursor").unwrap(); + spec.command = "/registry path/dist-package/cursor-agent".into(); + assert_eq!( + describe(&spec).login_hint.as_deref(), + Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.") + ); + } + #[test] fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index ca9f69566..4f9116546 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -11,6 +11,7 @@ struct FakeAgent { calls: Mutex>, /// Without `loadSession` (a session lives only as long as its process). no_load: bool, + auth_on_new: bool, /// Config options `session/new` reports after the model one (per-model efforts, say). extra_options: Mutex>, } @@ -87,6 +88,11 @@ impl Handler for FakeAgent { fn on_request(&self, method: &str, params: Value) -> Result { self.calls.lock().push(method.to_owned()); + if (method == "session/new" && self.auth_on_new) + || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) + { + return Err((codes::INTERNAL_ERROR, "Authentication required".into())); + } let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); match method { "initialize" => { @@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentMan ) } +#[tokio::test(flavor = "multi_thread")] +async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() { + for auth_on_new in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() }); + let mgr = manager(tmp.path(), agent); + let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd"); + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + program.to_string_lossy().into_owned(); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "cursor".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + if !auth_on_new { + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // A settings change must not redirect guidance for the already running process. + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + "different-cursor".into(); + mgr.prompt(&task.id, text("auth"), false).await.unwrap(); + } + wait_for(&mgr, &task.id, AgentStatus::Failed).await; + let entries = mgr.entries(&task.id, None).unwrap(); + let error = entries + .iter() + .find_map(|e| match e { + AgentEntry::Error { message, .. } => Some(message), + _ => None, + }) + .expect("authentication error"); + assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}"); + assert!(error.contains(&*program.to_string_lossy()), "{error}"); + assert!(error.ends_with(" login` in a terminal."), "{error}"); + mgr.shutdown(); + } +} + #[tokio::test(flavor = "multi_thread")] async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 6aa79979b..a44c98a15 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -346,7 +346,7 @@ impl Drop for KillOnDrop { /// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so /// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else /// is kept as given. -fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { +pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { let path = std::path::Path::new(command); if path.components().count() > 1 || path.is_absolute() { return path.to_path_buf(); diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 13a4c879b..9b32a16bf 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -1,7 +1,7 @@ /** Built-in launch settings; each executable is staged independently until Apply. */ import { useCallback, useEffect, useState } from "react"; import type { AgentProvider } from "@/bindings"; -import { tx, useTx } from "@/platform/i18n"; +import { tx, useTxTemplate } from "@/platform/i18n"; import clsx from "clsx"; import { Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui"; import { useSettingsForm } from "@/workbench/contributions"; @@ -242,7 +242,7 @@ const formatEnv = (env: Record) => /** Login hints are plain text with optional backtick-delimited command spans. */ function LoginHint({ text }: { text: string }) { - const translate = useTx(); + const translate = useTxTemplate(); return translate(text) .split(/(`[^`]+`)/g) .map((part, i) => (i % 2 === 1 ? {part.slice(1, -1)} : part)); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index a33171219..35e97cb82 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -58,6 +58,7 @@ import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { ImagePreview, imageSrc } from "./ImagePreview"; import { AgentReply } from "./AgentReply"; import { Markdown } from "./Markdown"; +import { useAgentErrorText } from "./loginHint"; import { branchLabel, entriesKey, @@ -866,12 +867,16 @@ const Entry = memo(function Entry({ return (
- {entry.message} +
); } }); +function AgentErrorText({ message }: { message: string }) { + return {useAgentErrorText(message)}; +} + function UserMessage({ task, entry, diff --git a/src/features/agents/loginHint.test.ts b/src/features/agents/loginHint.test.ts new file mode 100644 index 000000000..056c105cb --- /dev/null +++ b/src/features/agents/loginHint.test.ts @@ -0,0 +1,26 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { useAgentErrorText } from "./loginHint"; + +afterEach(() => _resetI18nForTests()); + +it.each([ + ["Run `cursor-agent login` in a terminal.", "在终端中运行 `cursor-agent login`。"], + ["Run `'/registry path/cursor-agent' login` in a terminal.", "在终端中运行 `'/registry path/cursor-agent' login`。"], + [ + "Run `& 'C:\\Agent Tools\\cursor-agent.cmd' login` in a terminal.", + "在终端中运行 `& 'C:\\Agent Tools\\cursor-agent.cmd' login`。", + ], + ["Run `'/registry/claude'` in a terminal and sign in.", "在终端中运行 `'/registry/claude'` 并登录。"], +])("translates sign-in guidance with its command intact: %s", async (hint, translated) => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText(`Cursor needs you to sign in. ${hint}`)); + expect(result.current).toBe(`Cursor 需要你登录。${translated}`); +}); + +it("preserves external agent errors", async () => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed")); + expect(result.current).toBe("Agent says: authentication failed"); +}); diff --git a/src/features/agents/loginHint.ts b/src/features/agents/loginHint.ts new file mode 100644 index 000000000..231dc0a26 --- /dev/null +++ b/src/features/agents/loginHint.ts @@ -0,0 +1,9 @@ +import { useTx, useTxTemplate } from "@/platform/i18n"; + +/** Translate the IDE's sign-in guidance while preserving the agent's own errors. */ +export function useAgentErrorText(message: string): string { + const tx = useTx(); + const translate = useTxTemplate(); + const auth = /^(.+) needs you to sign in\. (.+)$/s.exec(message); + return auth ? tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}` : message; +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index c2fbc3ede..fa5e2c547 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,7 @@ { + "Run `{command}` in a terminal.": "Run `{command}` in a terminal.", + "Run `{command}` in a terminal and sign in.": "Run `{command}` in a terminal and sign in.", + "{name} needs you to sign in. {hint}": "{name} needs you to sign in. {hint}", "OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings", "Import JetBrains Settings": "Import JetBrains Settings", "These settings will be imported:": "These settings will be imported:", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index dcb8f27c4..f086722b0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,7 @@ { + "Run `{command}` in a terminal.": "在终端中运行 `{command}`。", + "Run `{command}` in a terminal and sign in.": "在终端中运行 `{command}` 并登录。", + "{name} needs you to sign in. {hint}": "{name} 需要你登录。{hint}", "Cannot copy reference": "无法复制引用", "Group actions": "分组操作", "New Project Group": "新建项目组", From 1891119f9d456223bc9c6be7d0e373013d1f9f9c Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:30:28 +0100 Subject: [PATCH 5/6] feat(plugins): filter file events by path and resolve a document's project (#2003) Adds API 1.3 `events.subscribe-paths(kind, globs)` for `file-opened` and `file-saved`: project-relative globs checked before an event is queued or wakes an idle plugin, and again before a queued event is delivered. A new filter replaces the old one atomically; `subscribe` restores unfiltered delivery; filters survive idle reclamation. Also `workspace.project-of(path)`, the most specific open project containing a document (reading it still needs `project.read`). Closes #1958. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 27 +- crates/ide-host/src/plugins/event_paths.rs | 176 +++++++++ crates/ide-host/src/plugins/mod.rs | 18 +- crates/ide-host/tests/plugins.rs | 99 +++++- crates/ide-plugin/src/lib.rs | 2 +- crates/ide-plugin/src/runtime.rs | 17 +- crates/ide-plugin/src/sandbox.rs | 2 +- crates/ide-plugin/src/wit_host.rs | 17 +- .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../endpoints/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/path-events/Cargo.lock | 333 ++++++++++++++++++ .../tests/fixtures/path-events/Cargo.toml | 20 ++ .../fixtures/path-events/dist/plugin.wasm | Bin 0 -> 92727 bytes .../path-events/dist/plugin.wasm.sources | 1 + .../tests/fixtures/path-events/plugin.toml | 34 ++ .../tests/fixtures/path-events/src/lib.rs | 47 +++ .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../fixtures/tables/dist/plugin.wasm.sources | 2 +- plugins/README.md | 21 ++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 21 ++ sdk/rust/openidea-plugin/src/mock.rs | 96 ++++- sdk/rust/openidea-plugin/wit/events.wit | 4 + sdk/rust/openidea-plugin/wit/workspace.wit | 4 + wit/events.wit | 4 + wit/workspace.wit | 4 + 43 files changed, 949 insertions(+), 42 deletions(-) create mode 100644 crates/ide-host/src/plugins/event_paths.rs create mode 100644 crates/ide-plugin/tests/fixtures/path-events/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/path-events/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/path-events/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/path-events/src/lib.rs diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 4c55f35e4..418ace365 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -188,7 +188,7 @@ pub(super) struct Shared { contexts: Mutex>, closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). - pub subscriptions: Mutex>>, + pub subscriptions: Mutex, /// plugin → files it shows problems for, withdrawn when it stops. diagnostics: Mutex>>, pub secrets: RwLock>, @@ -331,7 +331,7 @@ impl Shared { let _ = workspace.publish_diagnostics(plugin, &path, Vec::new()); } } - if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { + if subscriptions && self.subscriptions.lock().remove(plugin) { self.sink.emit(None, PluginEvent::SubscriptionsChanged); } } @@ -1166,13 +1166,22 @@ impl HostApi for PluginApi { }) } - fn subscribe(&self, kind: EventKind, on: bool) { - let changed = { - let mut subs = self.shared.subscriptions.lock(); - let set = subs.entry(self.id().to_owned()).or_default(); - if on { set.insert(kind) } else { set.remove(&kind) } - }; - if changed { + fn subscribe(&self, kind: EventKind) { + if self.shared.subscriptions.lock().set(self.id(), kind) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError> { + let filter = super::event_paths::PathFilter::new(kind, globs)?; + if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + Ok(()) + } + + fn unsubscribe(&self, kind: &str) { + if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) { self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); } } diff --git a/crates/ide-host/src/plugins/event_paths.rs b/crates/ide-host/src/plugins/event_paths.rs new file mode 100644 index 000000000..b8ff5eb54 --- /dev/null +++ b/crates/ide-host/src/plugins/event_paths.rs @@ -0,0 +1,176 @@ +//! Project-relative event filters, kept with runtime subscriptions across idle reclaim. +use super::{LoadedPlugin, PluginHost}; +use globset::{GlobBuilder, GlobMatcher}; +use ide_plugin::{EventKind, HostError, WorkbenchEvent}; +use std::{ + collections::{BTreeSet, HashMap}, + path::Path, +}; + +#[derive(Clone, Default)] +pub(super) struct Subscriptions { + pub kinds: HashMap>, + pub paths: HashMap>, +} + +#[derive(Clone)] +pub(super) struct PathFilter { + pub globs: Vec, + matchers: Vec, +} + +impl PathFilter { + pub fn new(kind: &str, globs: Vec) -> Result { + if !matches!(kind, "file-opened" | "file-saved") { + return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into())); + } + if globs.len() > 64 { + return Err(HostError::Invalid("at most 64 event path globs".into())); + } + let mut matchers = Vec::new(); + for input in &globs { + let glob = input.trim_start_matches("./"); + if glob.is_empty() + || input.len() > 1024 + || glob.starts_with('/') + || glob.contains('\\') + || glob.contains(':') + || glob.chars().any(char::is_control) + || glob.split('/').any(|c| c == "..") + { + return Err(HostError::Invalid( + "event globs must be project-relative paths, at most 1024 bytes each".into(), + )); + } + matchers.push( + GlobBuilder::new(glob) + .literal_separator(true) + .build() + .map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))? + .compile_matcher(), + ); + } + Ok(Self { globs, matchers }) + } + + pub fn matches(&self, root: &Path, path: &Path) -> bool { + let path = ide_plugin::resolve_real(path); + let root = ide_plugin::resolve_real(root); + path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative))) + } +} + +impl Subscriptions { + pub fn remove(&mut self, plugin: &str) -> bool { + self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some() + } + /// Subscribes to every event of `kind`, dropping its path filter. + pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some()); + self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed + } + pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool { + let mut changed = false; + if let Some(kind) = EventKind::parse(kind) { + changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind); + } + let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone()); + previous.is_none_or(|old| old.globs != filter.globs) | changed + } + pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some()); + let off = EventKind::parse(kind) + .is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind))); + removed | off + } +} + +impl PluginHost { + pub(super) fn filter_path_events( + &self, + plugin: &LoadedPlugin, + client: Option<&str>, + events: &[WorkbenchEvent], + subs: &Subscriptions, + ) -> Vec { + let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() }; + let workspace = self.shared.workspace(); + events + .iter() + .filter(|event| { + let path = match event { + WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path), + _ => return true, + }; + let Some(filter) = filters.get(event.kind().name()) else { return true }; + if !path.is_absolute() { + return false; + } + let Some(workspace) = &workspace else { return false }; + let root = match client { + Some(client) => workspace.project_of(client), + None => { + let real = ide_plugin::resolve_real(path); + workspace + .projects() + .into_iter() + .filter(|r| real.starts_with(ide_plugin::resolve_real(r))) + .max_by_key(|r| r.components().count()) + } + }; + let Some(root) = root else { return false }; + if plugin.dev_root.as_deref().is_some_and(|r| r != root) { + return false; + } + filter.matches(&root, path) + }) + .cloned() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_validation_is_bounded_and_project_relative() { + for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] { + assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}"); + } + assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err()); + assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err()); + assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err()); + assert!(PathFilter::new("file-saved", vec![]).is_ok()); + } + + #[test] + fn matchers_use_or_semantics_and_component_boundaries() { + let root = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(root.path()).unwrap(); + let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap(); + for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] { + assert!(filter.matches(&root, &root.join(path)), "{path}"); + } + for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] { + assert!(!filter.matches(&root, &root.join(path)), "{path}"); + } + assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml"))); + assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a"))); + } + + #[test] + fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() { + let mut subs = Subscriptions::default(); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap()); + assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened)); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap()); + assert!(subs.paths["plugin"]["file-opened"].globs.is_empty()); + subs.set("plugin", EventKind::FileOpened); + assert!(subs.paths["plugin"].is_empty()); + subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap()); + assert!(subs.unsubscribe("plugin", "file-saved")); + assert!(subs.remove("plugin")); + assert!(subs.paths.is_empty() && subs.kinds.is_empty()); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0aeb50452..b02846219 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -18,6 +18,7 @@ mod api; pub mod commit; pub mod endpoints; +mod event_paths; mod hover; mod icon_extensions; mod icon_patterns; @@ -1450,7 +1451,7 @@ impl PluginHost { let mut out = BTreeSet::new(); for p in self.plugins.read().values() { for kind in EventKind::ALL { - if self.wants(p, kind, subs.get(p.id())) { + if self.wants(p, kind, subs.kinds.get(p.id())) { out.insert(kind); } } @@ -1483,7 +1484,8 @@ impl PluginHost { let mut queues = self.queues.lock(); for p in plugins { let wanted: Vec = - events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); + events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect(); + let wanted = self.filter_path_events(&p, client, &wanted, &subs); if wanted.is_empty() { continue; } @@ -1563,6 +1565,18 @@ impl PluginHost { /// Returns whether the plugin was activated. fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { let Ok(plugin) = self.get(id) else { return false }; + let subs = self.shared.subscriptions.lock().clone(); + let events: Vec<_> = self + .filter_path_events(&plugin, client.as_deref(), &events, &subs) + .into_iter() + .filter(|event| { + !matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_)) + || self.wants(&plugin, event.kind(), subs.kinds.get(id)) + }) + .collect(); + if events.is_empty() { + return false; + } if !plugin.meta.lock().pending.is_empty() { return false; } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index af9bf0d69..a5ff31292 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -19,6 +19,7 @@ type Events = Arc, PluginEvent)>>>; /// What plugins see of the test project: real `Documents`, as in the IDE. struct TestWorkspace { root: PathBuf, + other_roots: Mutex>, documents: Arc, trusted: bool, /// (producer, path) → published diagnostics. @@ -35,6 +36,7 @@ impl TestWorkspace { ); Arc::new(Self { root: root.to_path_buf(), + other_roots: Mutex::default(), documents: Arc::new(documents), trusted: true, diagnostics: Mutex::default(), @@ -67,7 +69,7 @@ impl TestWorkspace { impl PluginWorkspace for TestWorkspace { fn projects(&self) -> Vec { - vec![self.root.clone()] + std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect() } fn buffer_text(&self, path: &Path) -> Option { self.documents.text_of(path) @@ -1653,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() { f.install(&official("git-toolbox")); let untrusted = Arc::new(TestWorkspace { root: f.project.clone(), + other_roots: Mutex::default(), documents: f.workspace.documents.clone(), trusted: false, diagnostics: Mutex::default(), @@ -3565,3 +3568,97 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. }))); } } + +#[test] +fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); + f.install(&sample("path-events")); + f.run("test.path-events", "count", None).unwrap(); + let matched = to_ipc(&f.project.join("config/app.yaml")); + let other = to_ipc(&f.project.join("src/main.rs")); + let deliver = |events| { + f.host.post_events(Some("main"), events); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + }; + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.status("test.path-events"), PluginStatus::Inactive); + let activations = f.host.info_of("test.path-events").unwrap().stats.activations; + deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]); + assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2")); + assert!(f.run("test.path-events", "filter", Some("../*")).is_err()); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "invalid filter preserves previous one" + ); + + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]); + f.run("test.path-events", "filter", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "queued event uses current filter" + ); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3")); + f.run("test.path-events", "all", None).unwrap(); + deliver(vec![WorkbenchEvent::FileOpened(other.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]); + f.run("test.path-events", "off", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + deliver(vec![WorkbenchEvent::FileOpened(other)]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + + f.host.set_enabled("test.path-events", false).unwrap(); + assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind())); +} + +#[test] +fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() { + let f = fixture(Limits::default()); + f.install(&sample("path-events")); + let path = f.project.join("src/notes.txt"); + assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project))); + assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(), + None + ); + let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() }; + let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0)); + assert_eq!(hovers.len(), 1); + assert_eq!(hovers[0].markdown, to_ipc(&f.project)); + let nested = f.project.join("nested"); + std::fs::create_dir(&nested).unwrap(); + f.workspace.other_roots.lock().push(nested.clone()); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(), + None + ); + } + f.host.set_capability("test.path-events", "project.read", false).unwrap(); + assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read")); + f.host.set_capability("test.path-events", "events", false).unwrap(); + assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events")); +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 1b417b2b0..1878bb790 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,6 +20,6 @@ pub use runtime::{ API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api, component_api, host_api, }; -pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root}; +pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real}; pub use types::*; pub use wasmtime::component::Component; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index e0bdf562b..28991a97b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -110,6 +110,17 @@ pub trait HostApi: Send + Sync { } /// Roots of the open projects. fn projects(&self) -> Vec; + /// The most specific open project containing a document (including a new file). + fn project_of(&self, path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let real = crate::resolve_real(path); + self.projects() + .into_iter() + .filter(|root| real.starts_with(crate::resolve_real(root))) + .max_by_key(|root| root.components().count()) + } /// Unsaved text of an open editor's file. fn buffer_text(&self, path: &Path) -> Option; /// Version and text of the open document at `path`. @@ -166,7 +177,11 @@ pub trait HostApi: Send + Sync { cancel: &CancellationToken, timeout: Duration, ) -> Result; - fn subscribe(&self, kind: EventKind, on: bool); + /// Delivers every event of `kind`, dropping its path filter. + fn subscribe(&self, kind: EventKind); + /// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`. + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError>; + fn unsubscribe(&self, kind: &str); /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; fn task_report(&self, id: u64, done: u32, total: Option, message: Option); diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 7d966ff16..71a03d615 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -21,7 +21,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> { /// the canonical path when it exists, else the canonicalized deepest existing /// ancestor with the still-missing tail resolved textually — so the location /// of a missing path is still judged where it would really land. -fn resolve_real(joined: &Path) -> PathBuf { +pub fn resolve_real(joined: &Path) -> PathBuf { let mut tail = Vec::new(); let mut candidate = joined.to_path_buf(); let mut real = loop { diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 568e3b03c..bf9d92b09 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -460,6 +460,10 @@ impl workspace::Host for StoreData { Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit)) } + fn project_of(&mut self, path: String) -> wasmtime::Result>> { + self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose() + } + fn projects(&mut self) -> Fallible>> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -758,14 +762,19 @@ impl events::Host for StoreData { { return Ok(Err(e)); } - self.host.subscribe(kind, true); + self.host.subscribe(kind); Ok(Ok(())) } - fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { - if let Some(kind) = EventKind::parse(&kind) { - self.host.subscribe(kind, false); + fn subscribe_paths(&mut self, kind: String, globs: Vec) -> Fallible<()> { + if let Err(e) = self.require(Capability::Events) { + return Ok(Err(e)); } + Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit)) + } + + fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { + self.host.unsubscribe(&kind); Ok(()) } } diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 65cc7254d..80f5f73b6 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6 +627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 2363d3bf4..d1fd3db1c 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b +56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 3f4dc2f39..5b3b1f113 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b +d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 40bf2f7cf..4eb5d092c 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6 +21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 0418a5dbd..f5a605f66 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a +636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index a001ad8cc..6debc7b53 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3 +a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index ef2677b58..3afdf228b 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a +a8b92a0ee9b46d8d414457ff287d6092e85d4536e913d146aa49662814ff1836 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 96e1c60f6..5c04746d3 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540 +2fa3d6146a99eee79d4aec12bf8777369ae5cc76c3f7d6a8c254d5a27d58eeb9 diff --git a/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock b/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock new file mode 100644 index 000000000..9295140e5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-path-events" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml new file mode 100644 index 000000000..f4ec67ef5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-path-events" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..043a98de0a31ec29e20d39564fcc002cf39e5e92 GIT binary patch literal 92727 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 X|H>xwauc`ZA=ebWE}QICXP*B*cNsgg literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources new file mode 100644 index 000000000..56dee4cff --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -0,0 +1 @@ +c14d91010021e3b35f6ae29a2a327bb3ad66087f07cfd744814443862c0050cc diff --git a/crates/ide-plugin/tests/fixtures/path-events/plugin.toml b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml new file mode 100644 index 000000000..c959419ad --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml @@ -0,0 +1,34 @@ +id = "test.path-events" +name = "Path event fixture" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["storage"] +optional = ["events", "project.read"] + +[[contributes.providers]] +kinds = ["hover"] +languages = ["text", "plaintext"] + +[[contributes.commands]] +id = "project" +title = "project" + +[[contributes.commands]] +id = "filter" +title = "filter" + +[[contributes.commands]] +id = "all" +title = "all" + +[[contributes.commands]] +id = "off" +title = "off" + +[[contributes.commands]] +id = "count" +title = "count" diff --git a/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs new file mode 100644 index 000000000..fc9a8eed3 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs @@ -0,0 +1,47 @@ +use openidea_plugin::hover::{Document, Hover, HoverProvider}; +use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin}; + +#[derive(Default)] +struct Fixture; + +impl Plugin for Fixture { + fn activate(&mut self, _: &Context) -> Result<(), Error> { + // Persist the chosen filter so reactivation does not overwrite it. + let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into()); + let globs: Vec<_> = glob.lines().collect(); + let _ = host::subscribe_paths("file-opened", &globs); + let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]); + Ok(()) + } + fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(), + "filter" => { + let glob = arg.unwrap_or_default(); + host::subscribe_paths("file-opened", &glob.lines().collect::>())?; + host::storage::set("filter", glob)?; + Ok(None) + } + "all" => { + host::subscribe(EventKind::FileOpened)?; + Ok(None) + } + "off" => { + host::unsubscribe(EventKind::FileOpened); + Ok(None) + } + "count" => Ok(host::storage::get("count")?), + _ => Err(Error::NotFound(id.into())), + } + } + fn handle_events(&mut self, events: &[Event], _: &Invocation) { + let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::().ok()).unwrap_or(0); + host::storage::set("count", &(count + events.len()).to_string()).unwrap(); + } +} +impl HoverProvider for Fixture { + fn hover(&mut self, doc: &Document, _: Position) -> Option { + Some(Hover::new(host::project_of(&doc.path)?.root().ok()?)) + } +} +register_plugin!(Fixture, hover); diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 0b3aeaf03..cffc162fd 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c +e9b6c10973ce62057eb3c92a747bcb27fdef1cfb6dd1d8681aa0035d3a3b5fa2 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 0f00f1fc0..207373302 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504 +a873a709240a3d24c45481b220ac283103f10645166952cb5dcc809f7995e085 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index d554de5bb..c4a7d7e70 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979 +8801a468856e0e4783848f89d51d6d6f45426e6cadf2b2532518500ff23aa6f7 diff --git a/plugins/README.md b/plugins/README.md index 61d491c93..4dd12940f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -419,6 +419,27 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON). The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm. +API 1.3 adds `host::subscribe_paths("file-opened", &["config/*.yaml"])` +(`events.subscribe-paths`). The host matches project-relative globs before +queueing an event or waking an idle plugin. `file-saved` works the same way. +Patterns use `/`, `*` stays within a directory, and `**` crosses +directories. Up to 64 patterns of 1024 bytes each are accepted; absolute paths, +parent traversal and malformed patterns fail without replacing the old filter. + +Register filters in `activate`, using a startup or command activation to install +them before observing events. Patterns are ORed; another call replaces the same +kind's filter, an empty list pauses it, `subscribe(kind)` restores all paths, +and `unsubscribe(kind)` removes it. Filters survive idle reclaim and disappear +on disable, uninstall or reload. Each window's events use its current project; +files outside that project do not match. + +Provider calls can use `host::project_of(&doc.path)` (`workspace.project-of`) +without an invocation project or enumerating every open project. It returns the +deepest open project containing the absolute path, or `None` outside open roots. +Symlinks and missing file tails are resolved before checking containment. +Obtaining a handle grants no read access: `Project::root` and file operations +still require `project.read`. + ### Commits A plugin can work on the commit the user is preparing, in the Commit tool diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 45abe7179..f7059eb82 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316 +d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ede72b964..47b51c2b6 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf +c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index f934d73f2..00ba69d61 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a +f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 024e3ba88..7f5ca161e 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696 +648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index fab3d5b66..f1e73887b 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892 +f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index cb3f47b28..4cd2641dd 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852 +936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 2f706ae66..bf49a1220 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809 +182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 3e5f9a1d5..d2cba9a41 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a +f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index f6e51b94e..6b1a93310 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268 +4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index fe62a5e73..33245bb32 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949 +e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ff114fb2c..5813764a4 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb +318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 0d9d9b01f..b08e3df37 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -182,6 +182,10 @@ mod imp { wit::workspace::projects().map_err(error) } + pub fn project_of(path: &str) -> Option { + wit::workspace::project_of(path) + } + pub fn project_root(p: &ProjectRes) -> Result { p.root().map_err(error) } @@ -278,6 +282,10 @@ mod imp { } } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + wit::events::subscribe_paths(kind, &globs.iter().map(|g| (*g).to_owned()).collect::>()).map_err(error) + } + pub fn subscribe(k: EventKind) -> Result<(), Error> { wit::events::subscribe(k.name()).map_err(error) } @@ -621,6 +629,12 @@ pub fn projects() -> Result, Error> { Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) } +/// The deepest open project containing an absolute document path, including in providers. +/// Obtaining the handle needs no capability; reading it still needs `project.read`. +pub fn project_of(path: &str) -> Option { + imp::project_of(path).map(|inner| Project { inner }) +} + /// A file open in an editor. pub struct TextEditor { inner: imp::EditorRes, @@ -790,6 +804,13 @@ pub fn locale() -> String { imp::locale() } +/// Subscribe to project-relative path globs for file-opened or file-saved. +/// Replaces the previous filter. Empty globs pause delivery; `subscribe` restores all paths. +/// Requires `events`. Register during activation before the events you want to observe. +pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + imp::subscribe_paths(kind, globs) +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index fb0c05b44..a01e8f510 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -72,6 +72,7 @@ struct State { context: BTreeMap, storage: BTreeMap, subscriptions: BTreeSet, + path_subscriptions: BTreeMap>, tasks: Vec, vcs: Option, remotes: Vec<(String, String)>, @@ -230,6 +231,10 @@ impl MockHost { self.state.borrow().storage.clone() } + pub fn path_subscriptions(&self) -> BTreeMap> { + self.state.borrow().path_subscriptions.clone() + } + pub fn subscriptions(&self) -> BTreeSet { self.state.borrow().subscriptions.clone() } @@ -613,6 +618,27 @@ pub mod current { }) } + pub fn project_of(path: &str) -> Option { + let path = path.replace('\\', "/"); + let absolute = + path.starts_with('/') || (path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')); + if !absolute || path.split('/').any(|part| part == "..") { + return None; + } + with(|s| { + s.root + .iter() + .chain(&s.other_projects) + .filter(|root| { + let root = root.replace('\\', "/"); + path == root + || path.strip_prefix(root.trim_end_matches('/')).is_some_and(|rest| rest.starts_with('/')) + }) + .max_by_key(|root| root.replace('\\', "/").split('/').count()) + .map(|root| MockProject { root: root.clone() }) + }) + } + pub fn project_root(p: &ProjectRes) -> Result { with(|s| { require(s, "project.read")?; @@ -797,6 +823,38 @@ pub mod current { }) } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + with(|s| { + require(s, "events")?; + if !matches!(kind, "file-opened" | "file-saved") + || globs.len() > 64 + || globs.iter().any(|g| { + let relative = g.trim_start_matches("./"); + relative.is_empty() + || g.len() > 1024 + || relative.starts_with('/') + || g.contains('\\') + || g.contains(':') + || g.chars().any(char::is_control) + || g.split('/').any(|p| p == "..") + }) + { + return Err(Error::Invalid("invalid event path filter".into())); + } + match kind { + "file-opened" => { + s.subscriptions.insert(EventKind::FileOpened); + } + "file-saved" => { + s.subscriptions.insert(EventKind::FileSaved); + } + _ => {} + } + s.path_subscriptions.insert(kind.to_owned(), globs.iter().map(|g| (*g).to_owned()).collect()); + Ok(()) + }) + } + pub fn subscribe(kind: EventKind) -> Result<(), Error> { with(|s| { let cap = match kind { @@ -807,13 +865,17 @@ pub mod current { if let Some(cap) = cap { require(s, cap)?; } + s.path_subscriptions.remove(kind.name()); s.subscriptions.insert(kind); Ok(()) }) } pub fn unsubscribe(kind: EventKind) { - with(|s| s.subscriptions.remove(&kind)); + with(|s| { + s.path_subscriptions.remove(kind.name()); + s.subscriptions.remove(&kind); + }); } pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result, Error> { @@ -965,6 +1027,38 @@ mod tests { use super::*; use crate::host; + #[test] + fn path_subscriptions_replace_pause_and_restore() { + let mock = MockHost::install(); + host::subscribe_paths("file-opened", &["config/*.yaml"]).unwrap(); + assert_eq!(mock.path_subscriptions()["file-opened"], ["config/*.yaml"]); + host::subscribe_paths("file-opened", &[]).unwrap(); + assert!(mock.path_subscriptions()["file-opened"].is_empty()); + assert!(host::subscribe_paths("file-opened", &["../secret"]).is_err()); + host::subscribe(EventKind::FileOpened).unwrap(); + assert!(mock.path_subscriptions().is_empty()); + host::subscribe_paths("file-saved", &["**/*.rs"]).unwrap(); + host::unsubscribe(EventKind::FileSaved); + assert!(mock.path_subscriptions().is_empty()); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::subscribe_paths("file-opened", &[]), Err(Error::Denied(_)))); + } + + #[test] + fn project_lookup_uses_deepest_root_without_granting_read_access() { + let mock = MockHost::install(); + mock.add_project("/project/nested"); + assert_eq!(host::project_of("/project/nested/new.txt").unwrap().root().unwrap(), "/project/nested"); + assert!(host::project_of("/project-other/file").is_none()); + assert!(host::project_of("relative/file").is_none()); + mock.add_project("C:/workspace"); + assert_eq!(host::project_of(r"C:\workspace\file.txt").unwrap().root().unwrap(), "C:/workspace"); + mock.add_project("//server/share"); + assert_eq!(host::project_of(r"\\server\share\file.txt").unwrap().root().unwrap(), "//server/share"); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::project_of("/project/file").unwrap().root(), Err(Error::Denied(_)))); + } + #[test] fn invocations_carry_selected_paths() { let host = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 92175d555..ffb86d39b 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -36,6 +36,10 @@ interface events { /// and an IDE that doesn't know one fails with `invalid`. /// Requires `events` (`events.input` for `action-performed`). subscribe: func(kind: string) -> result<_, error>; + /// Replaces this kind's subscription with project-relative globs (OR). + /// Empty globs pause delivery; `subscribe` restores unfiltered delivery. + /// Kinds: file-opened, file-saved. Requires `events`. + subscribe-paths: func(kind: string, globs: list) -> result<_, error>; unsubscribe: func(kind: string); } diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index b31964e8b..c764d50e8 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -8,6 +8,10 @@ interface workspace { /// `path` is an absolute directory path on the UI machine. open-project: func(path: string, new-window: bool) -> result<_, error>; + /// The open project containing an absolute document path, or `none`. + /// Reading the returned project still requires `project.read`. + project-of: func(path: string) -> option; + /// What a path inside a project is. enum file-kind { file, diff --git a/wit/events.wit b/wit/events.wit index 92175d555..ffb86d39b 100644 --- a/wit/events.wit +++ b/wit/events.wit @@ -36,6 +36,10 @@ interface events { /// and an IDE that doesn't know one fails with `invalid`. /// Requires `events` (`events.input` for `action-performed`). subscribe: func(kind: string) -> result<_, error>; + /// Replaces this kind's subscription with project-relative globs (OR). + /// Empty globs pause delivery; `subscribe` restores unfiltered delivery. + /// Kinds: file-opened, file-saved. Requires `events`. + subscribe-paths: func(kind: string, globs: list) -> result<_, error>; unsubscribe: func(kind: string); } diff --git a/wit/workspace.wit b/wit/workspace.wit index b31964e8b..c764d50e8 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -8,6 +8,10 @@ interface workspace { /// `path` is an absolute directory path on the UI machine. open-project: func(path: string, new-window: bool) -> result<_, error>; + /// The open project containing an absolute document path, or `none`. + /// Reading the returned project still requires `project.read`. + project-of: func(path: string) -> option; + /// What a path inside a project is. enum file-kind { file, From dc69c36604fc2d8e0c2fa0e8b7b2afb1ec0c505f Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:36:59 +0100 Subject: [PATCH 6/6] feat(plugins): read-only form fields and table row interactions (#2013) API 1.3 through the released `other` extension cases, no WIT change: form fields `label` (plain value) and `readonly-text` (selectable, optionally multi-line, drawn as read-only), and table view-events `table-select` / `table-activate` (click, Space; double-click, Enter) with absolute row indices. Table rows also get the view's `viewItem.context` commands, keyed by row. SDK: `FieldValue::label`, `FieldValue::readonly_text`, `ViewEvent::table_select` / `table_activate`. Closes #1953. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/tests/plugins.rs | 24 +++ crates/ide-plugin/src/runtime.rs | 63 +++++- crates/ide-plugin/src/types.rs | 4 + .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../endpoints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/hello/dist/plugin.wasm | Bin 143251 -> 143251 bytes .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 150169 -> 150169 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../path-events/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/tables/dist/plugin.wasm | Bin 115788 -> 119171 bytes .../fixtures/tables/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/tables/plugin.toml | 19 +- .../tests/fixtures/tables/src/lib.rs | 48 ++++- plugins/README.md | 20 ++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- plugins/official/csv-editor/dist/plugin.wasm | Bin 133421 -> 133421 bytes .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../official/regex-tester/dist/plugin.wasm | Bin 931754 -> 931754 bytes .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/views.rs | 51 +++++ src/bindings.ts | 7 +- src/features/plugins/__tests__/views.test.tsx | 182 +++++++++++++++++- .../plugins/views/PluginView.module.css | 19 ++ src/features/plugins/views/PluginView.tsx | 60 +++++- 39 files changed, 502 insertions(+), 41 deletions(-) diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index a5ff31292..e961f51b7 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3569,6 +3569,30 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab } } +#[test] +fn plugin_readonly_fields_and_table_row_extensions_round_trip() { + let f = fixture(Limits::default()); + f.install(&sample("tables")); + let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into())); + assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false }); + assert_eq!( + fields[2].value, + ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true } + ); + assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_))); + for (event, status) in [ + (ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"), + (ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"), + ] { + f.take_events(); + f.host.view_event("tables", "tables", event, f.inv()).unwrap(); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables"))); + assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status)); + } +} + #[test] fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() { let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 28991a97b..35f154751 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1619,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View { options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(), selected: clip(selected, MAX_TITLE), }, - // A field type of a newer API: left out. - wit_vt::FieldValue::Other(_) => return None, + wit_vt::FieldValue::Other(e) => match e.kind.as_str() { + "label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)), + "readonly-text" => { + #[derive(serde::Deserialize)] + struct ReadonlyText { + text: String, + #[serde(default)] + multiline: bool, + } + let value: ReadonlyText = serde_json::from_str(&e.data).ok()?; + FieldValue::ReadonlyText { + text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }), + multiline: value.multiline, + } + } + _ => return None, + }, }; Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value }) }) @@ -1673,6 +1688,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { ViewEvent::InsertRow { before } => W::InsertRow(before), ViewEvent::DeleteRows { rows } => W::DeleteRows(rows), ViewEvent::Message { text } => W::Message(text), + ViewEvent::TableSelect { row } => { + W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) }) + } + ViewEvent::TableActivate { row } => { + W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) }) + } // A sortable table's header click, as an `other` view-event (plugins/ // README.md §10): the SDKs decode the kinds they know. ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension { @@ -1686,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { mod tests { use super::*; + #[test] + fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() { + let fields = [ + ("label", r#""Title \"quoted\"""#), + ("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#), + ("readonly-text", r#"{"text":"author"}"#), + ("label", "{}"), + ("readonly-text", r#"{"text":false}"#), + ("future", "null"), + ] + .into_iter() + .enumerate() + .map(|(i, (kind, data))| wit_vt::Field { + key: i.to_string(), + label: "Field".into(), + value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }), + }) + .collect(); + let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None }); + let ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into())); + assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true }); + assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false }); + } + + #[test] + fn table_row_events_leave_through_released_extension_case() { + for (event, kind) in [ + (ViewEvent::TableSelect { row: 51 }, "table-select"), + (ViewEvent::TableActivate { row: 51 }, "table-activate"), + ] { + let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") }; + assert_eq!(extension.kind, kind); + assert_eq!(extension.data, r#"{"row":51}"#); + } + } + #[cfg(windows)] #[test] fn provider_and_resolved_action_paths_match_ipc_events() { diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 357f459aa..d12365203 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -527,6 +527,8 @@ pub struct ViewListItem { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "kind", content = "value")] pub enum FieldValue { + Label(String), + ReadonlyText { text: String, multiline: bool }, Text(String), Multiline(String), Checkbox(bool), @@ -644,6 +646,8 @@ pub enum ViewEvent { // A sortable table's header click: the column and the direction (leaves // as an `other` view-event, kind `sort`). See `TableSort`. Sort { column: u32, descending: bool }, + TableSelect { row: u32 }, + TableActivate { row: u32 }, } /// A plugin's HTTP route declaration in a project. diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 80f5f73b6..51d9c12f7 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4 +73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index d1fd3db1c..1b3352545 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662 +4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 37482b7f6909734c0c802dbd345841c60515e8aa..b01c003f5959c29aebc7e55bf4950cd35e20a7e1 100644 GIT binary patch delta 58 zcmV-A0LA~4-w2c62(T#_0|EgEvn&|hF9Fe$3P@oA(z9$x`;C)xFD8@y)-aQ2)7 Pjn*)OE!Vd#*8yf&l$RJH diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 5b3b1f113..41ad8e0bb 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88 +83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 4eb5d092c..f07847b94 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990 +bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index f5a605f66..ea69603ef 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37 +1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 6debc7b53..ac690f4be 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2 +17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index bf157c0429e6f47b47f8433311f850d452bc4aa9..ec949d20b1971fd4c7e71d2d0e94909df1363148 100644 GIT binary patch delta 64 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 Yy&?D*_kRTp?caFr46NHUn`SinfAN;1z5oCK delta 19680 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, + status: Option, + note: Option, } impl Tables { @@ -34,13 +36,34 @@ impl Tables { } impl Plugin for Tables { - fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + if id == "inspect" { + self.status = Some(format!("context:{}", arg.unwrap_or(""))); + host::refresh_view(VIEW); + return Ok(self.status.clone()); + } Err(Error::NotFound(format!("command {id}"))) } } impl Views for Tables { fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view == "fields" { + return Ok(View::new(Body::Form(vec![ + Field::new("title", "Title", FieldValue::label("Merge request #42")), + Field::new("author", "Author", FieldValue::readonly_text("Ada Lovelace", false)), + Field::new( + "description", + "Description", + FieldValue::readonly_text("Ready for review\nAll checks passed", true), + ), + Field::new( + "editable", + "Editable note", + FieldValue::Text(self.note.clone().unwrap_or_else(|| "Edit me".into())), + ), + ]))); + } if view != VIEW { return Err(Error::NotFound(view.into())); } @@ -51,13 +74,32 @@ impl Views for Tables { rows: self.rows(), editable: false, }; - Ok(View::new(Body::sortable_table(&table, self.sort))) + let mut view = View::new(Body::sortable_table(&table, self.sort)); + view.status = self.status.clone(); + Ok(view) } fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view == "fields" { + if let ViewEvent::Field(key, value) = event { + if key == "editable" { + self.note = Some(value); + host::refresh_view("fields"); + } + } + return Ok(()); + } if view != VIEW { return Err(Error::NotFound(view.into())); } + if let Some(row) = event.table_select() { + self.status = Some(format!("select:{row}")); + host::refresh_view(VIEW); + } + if let Some(row) = event.table_activate() { + self.status = Some(format!("activate:{row}")); + host::refresh_view(VIEW); + } // The host may have encoded the click for a newer SDK: the typed // helper reads the `other` kind `sort` this SDK knows. if let Some(sort) = event.sort() { diff --git a/plugins/README.md b/plugins/README.md index 4dd12940f..fa14654b0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -639,6 +639,26 @@ For a table the user can sort by clicking a header, build it with column headers clickable and asks for a column and direction with `ViewEvent::sort()` — sort your rows and render again; the sort you report shows as an arrow in the header. +API 1.3 adds read-only form values without changing the released field record: +`FieldValue::label(text)` uses `other { kind: "label", data }`, where `data` +is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind +`readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag +defaults to false). Labels render as plain selectable values; read-only text +keeps the single-line or multiline text control and never sends field events. + +Table row clicks and Space send `other` kind `table-select`; double-click +and Enter send `table-activate`, both with JSON `{ "row": 0 }`. +`ViewEvent::table_select()` and `table_activate()` decode the absolute, +zero-based index, including `first_row` for paged tables. These work for both +plain tables and the same `other` table shape used by sortable tables above; +no new table shape is needed. Editable cells still double-click to edit; +activate their row with Enter on the focused row or double-click its row number. +`viewItem.context` menus also work on table rows: the command argument and +`viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables +keep their insert/delete entries alongside contributed commands. After sorting +or changing data, plugins resolve row indices against their current row order. +Declare `engines.openidea = "^1.3"` when relying on these additions. + Bodies, field types and view events added by later APIs travel as `Other { kind, data }`: an IDE that doesn't know a body says it needs updating, and leaves out a field type it doesn't know. diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index f7059eb82..4c26aa7a6 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9 +abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 47b51c2b6..3937291d0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc +7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 7b5db1cf8af1e5f88249d052185d15142366415e..46f165e97e390a9de54bd6832f98e5993964dad4 100644 GIT binary patch delta 92 zcmV-i0Hgn{k_fGm2(VKW1_A*H0}TVSSrkGG0k^Z9AB6z{vy&nrUIDbTfg!V*0k*R> yyYUQ@`F19gyvQ(0%D4W?0WUtT7bW2U diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 00ba69d61..a994d32a5 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a +d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 7f5ca161e..0ff05c2b1 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781 +2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index f1e73887b..99dff7b05 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743 +4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 4cd2641dd..37f95f763 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174 +d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index bf49a1220..f8e01f0aa 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c +582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index d2cba9a41..e6a1f675b 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8 +3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 3c9407408c32ac0a72a08bce4e687e25a8acd7ba..81d96c8576543aef0148071d959d7e528681de0a 100644 GIT binary patch delta 117 zcmZ4W-eT2ziwzGYnVFfmHb0U4DaN>c@;!q<#vRQDhV2H1j6lo;#LPg<0>rF9%(mUY zkbSbv^iwI!YSSb0*bSy1&SiIE+%}ypk6m~B25Uxj#`X>Q>_E%`#GF9P1;pGy%(Hz% IKJTyN0IFju?EnA( delta 112 zcmZ4W-eT2ziwzGYnVGnlH$Rd5DaN>M@;!q<#_i1phV2H1j6lo;#LPg<0>rF9%(mUY zkbSbvbjdt+C&nGqWAfN_rytH`58J-Mno*sxeM3Gw5OV-AClGT1F*gwNY~PU2`|CIW D+~p}d diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 6b1a93310..3cd7203d1 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868 +2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 33245bb32..b9561d44b 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced +dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 5813764a4..c90232e94 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c +8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340 diff --git a/sdk/rust/openidea-plugin/src/views.rs b/sdk/rust/openidea-plugin/src/views.rs index 9acf2b9bd..bb6729c8d 100644 --- a/sdk/rust/openidea-plugin/src/views.rs +++ b/sdk/rust/openidea-plugin/src/views.rs @@ -125,6 +125,21 @@ pub enum FieldValue { }, } +impl FieldValue { + /// A plain read-only value, carried by `other` kind `label` (API 1.3). + pub fn label(text: &str) -> Self { + Self::Other { kind: "label".into(), data: format!("\"{}\"", crate::text::escape_json(text)) } + } + + /// Selectable text with no field-change events (API 1.3). + pub fn readonly_text(text: &str, multiline: bool) -> Self { + Self::Other { + kind: "readonly-text".into(), + data: format!(r#"{{"text":"{}","multiline":{multiline}}}"#, crate::text::escape_json(text)), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Field { pub key: String, @@ -273,6 +288,21 @@ pub enum ViewEvent { } impl ViewEvent { + /// The absolute zero-based row selected in a table (API 1.3). + pub fn table_select(&self) -> Option { + self.table_row("table-select") + } + + /// The absolute zero-based row activated in a table (API 1.3). + pub fn table_activate(&self) -> Option { + self.table_row("table-activate") + } + + fn table_row(&self, expected: &str) -> Option { + let Self::Other { kind, data } = self else { return None }; + (kind == expected).then(|| json_number(data, "row")).flatten() + } + /// The click on a sortable table's header ([`Body::sortable_table`]): the /// column and the direction to order it by (`other` view-event, kind /// `sort`; the first click asks ascending, a second on the same column @@ -342,6 +372,27 @@ pub trait Views { mod tests { use super::*; + #[test] + fn readonly_fields_escape_text_without_changing_released_variants() { + assert_eq!(FieldValue::label("a\"\n"), FieldValue::Other { kind: "label".into(), data: r#""a\"\n""#.into() }); + assert_eq!( + FieldValue::readonly_text("a\nβ", true), + FieldValue::Other { kind: "readonly-text".into(), data: r#"{"text":"a\nβ","multiline":true}"#.into() } + ); + } + + #[test] + fn table_row_events_only_decode_their_own_valid_payloads() { + let event = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() }; + assert_eq!(event("table-select", r#"{"row":51}"#).table_select(), Some(51)); + assert_eq!(event("table-activate", r#"{"row":0}"#).table_activate(), Some(0)); + for data in ["{}", r#"{"row":-1}"#, r#"{"row":4294967296}"#, r#"{"row":"1"}"#] { + assert_eq!(event("table-select", data).table_select(), None); + } + assert_eq!(event("select", r#"{"row":1}"#).table_select(), None); + assert_eq!(event("table-select", r#"{"row":1}"#).table_activate(), None); + } + #[test] fn a_sortable_table_becomes_the_other_table_body() { let table = Table { diff --git a/src/bindings.ts b/src/bindings.ts index a763377ba..beb15c9eb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3792,7 +3792,10 @@ export type FacetConfig = { webRoot?: string, }; -export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { +export type FieldValue = { kind: "label"; value: string } | { kind: "readonlyText"; value: { + text: string, + multiline: boolean, +} } | { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { options: string[], selected: string, } }; @@ -8127,7 +8130,7 @@ export type ViewButton = { }; /** A user action in a view. */ -export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean }; +export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean } | { kind: "tableSelect"; row: number } | { kind: "tableActivate"; row: number }; export type ViewField = { key: string, diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index e6a8a2247..8f008fe1b 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import type { View, ViewEvent } from "@/bindings"; @@ -419,6 +419,7 @@ describe("PluginView", () => { render(); await user.dblClick(await screen.findByText("London")); const input = screen.getByLabelText("Cell value"); + expect(events.some((e) => e.kind === "tableActivate")).toBe(false); await user.clear(input); await user.type(input, "Paris{Enter}"); await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" })); @@ -533,3 +534,182 @@ describe("PluginView", () => { expect(await screen.findByText("boom")).toBeInTheDocument(); }); }); + +describe("API 1.3 read-only values and table rows", () => { + it("shows selectable labels and readonly text without sending field changes", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "form", + value: [ + { key: "title", label: "Title", value: { kind: "label", value: "Merge request" } }, + { + key: "author", + label: "Author", + value: { kind: "readonlyText", value: { text: "Ada", multiline: false } }, + }, + { + key: "body", + label: "Description", + value: { kind: "readonlyText", value: { text: "one\ntwo", multiline: true } }, + }, + ], + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render(); + expect(await screen.findByLabelText("Title")).toHaveTextContent("Merge request"); + for (const label of ["Author", "Description"]) { + const input = screen.getByLabelText(label); + expect(input).toHaveAttribute("readonly"); + await user.type(input, "changed"); + await user.tab(); + } + expect(screen.getByLabelText("Author")).toHaveValue("Ada"); + expect(screen.getByLabelText("Description")).toHaveValue("one\ntwo"); + expect(events).toEqual([]); + }); + + it("keeps editable table row actions beside contributed commands", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Value"], + rows: [["Editable"]], + totalRows: 1, + firstRow: 0, + editable: true, + sortable: false, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + [{ label: "Inspect editable", run: () => {} }]} /> + , + ); + const cell = await screen.findByText("Editable"); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(screen.getByText("Inspect editable")).toBeInTheDocument(); + expect(screen.getByText("Insert Row Above")).toBeInTheDocument(); + await user.click(screen.getByText("Delete Row")); + expect(events).toContainEqual({ kind: "deleteRows", rows: [0] }); + hideContextMenu(); + }); + + it("uses absolute row indices after paging and supports keyboard context menus", async () => { + const events: ViewEvent[] = []; + const itemMenu = vi.fn(() => [{ label: "Inspect paged row", run: () => {} }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/paged"]], + totalRows: 100, + firstRow: 50, + editable: false, + sortable: true, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const grid = await screen.findByRole("grid"); + fireEvent.scroll(grid, { target: { scrollTop: 50 * 24 } }); + const cell = await screen.findByText("/paged"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 50 }); + (cell.closest('[role="row"]') as HTMLElement).focus(); + await user.keyboard("{Enter}{Shift>}{F10}{/Shift}"); + expect(events).toContainEqual({ kind: "tableActivate", row: 50 }); + expect(itemMenu).toHaveBeenCalledWith("50"); + expect(screen.getByText("Inspect paged row")).toBeInTheDocument(); + hideContextMenu(); + }); + + it.each([false, true])( + "sends absolute row events and contributed context commands (sortable=%s)", + async (sortable) => { + const events: ViewEvent[] = []; + const menuRun = vi.fn(); + const itemMenu = vi.fn((key: string) => [{ label: `Inspect ${key}`, run: menuRun }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/users"], ["/posts"]], + totalRows: 2, + firstRow: 0, + editable: false, + sortable, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const cell = await screen.findByText("/posts"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 1 }); + expect(cell.closest('[role="row"]')).toHaveAttribute("aria-selected", "true"); + await user.dblClick(cell); + expect(events).toContainEqual({ kind: "tableActivate", row: 1 }); + const row = cell.closest('[role="row"]') as HTMLElement; + row.focus(); + await user.keyboard("{Enter} "); + expect(events.filter((e) => e.kind === "tableActivate")).toHaveLength(2); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(itemMenu).toHaveBeenCalledWith("1"); + await user.click(screen.getByText("Inspect 1")); + expect(menuRun).toHaveBeenCalledOnce(); + expect(screen.queryByText("Insert Row Above")).not.toBeInTheDocument(); + hideContextMenu(); + }, + ); +}); diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css index d53ff8b24..b8f4c443a 100644 --- a/src/features/plugins/views/PluginView.module.css +++ b/src/features/plugins/views/PluginView.module.css @@ -226,3 +226,22 @@ width: var(--h-floating-toolbar-button); height: var(--h-floating-toolbar-button); } + +.readonlyValue { + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +/* Selectable, but framed like a disabled field so it doesn't read as editable. */ +.readonlyField:read-only { + border: 0; + border-radius: var(--radius-control, 4px); + background: transparent; + box-shadow: inset 0 0 0 1px var(--border); +} + +.tableRow:focus-visible { + outline: 1px solid var(--focus-ring); + outline-offset: -1px; +} diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index b92909f5b..669b2cb36 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -16,6 +16,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import clsx from "clsx"; import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; import { tx, VerbatimTextContext } from "@/platform/i18n"; import { @@ -46,7 +47,7 @@ export interface PluginViewProps { revision?: number; /** Called after an event was handled (custom editors re-render on the document instead). */ label?: string; - /** Context menu entries of a tree or list item, by item key. */ + /** Context menu entries by item key (tables use the absolute row index). */ itemMenu?: (key: string) => MenuItem[]; } @@ -203,7 +204,7 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps ); case "table": - return ; + return ; case "html": return ; } @@ -283,6 +284,17 @@ function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => {field.label} )} + {v.kind === "label" && ( + + {v.value} + + )} + {v.kind === "readonlyText" && + (v.value.multiline ? ( +