feat(plugins): let plugin views open a project file at a line (#1362)

Plugin tool windows that list code locations (routes, failed tests,
queries) had no way to take the user to file:line. A host import
`views.open-location(path, line, column)` opens a file inside the project
in an editor of the window the call came from, caret at the given line and
column (0-based, UTF-16). It needs `project.read` and refuses paths outside
the project; the event goes only to the calling window, as `open-terminal`
does. The Rust SDK exposes it as `host::open_location` (with the mock), the
API table lists it, and the shipped components are rebuilt for the new WIT.

Closes #1334

Co-authored-by: yzPeedro <54516747+yzPeedro@users.noreply.github.com>
This commit is contained in:
Pedro Pessoa
2026-11-05 07:56:02 +08:00
committed by GitHub
parent 7ffaa0b5da
commit 8d784ac164
42 changed files with 114 additions and 16 deletions
+11
View File
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
+2
View File
@@ -192,6 +192,8 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
+32
View File
@@ -2045,6 +2045,38 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
+1
View File
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
+15
View File
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Binary file not shown.
@@ -1 +1 @@
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
Binary file not shown.
@@ -1 +1 @@
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
+1
View File
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
+2
View File
@@ -64,6 +64,8 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
@@ -1 +1 @@
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
+1
View File
@@ -313,6 +313,7 @@ then run `openidea-plugin check`.
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
+8
View File
@@ -245,6 +245,10 @@ mod imp {
wit::views::open_terminal(title, program, args).map_err(error)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
wit::views::open_location(path, line, column).map_err(error)
}
pub fn setting(key: &str) -> Option<String> {
wit::settings::get(key)
}
@@ -387,6 +391,10 @@ pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(),
imp::open_terminal(title, program, args)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
imp::open_location(path, line, column)
}
/// One of the plugin's settings (`[contributes.settings]`) as JSON: the
/// user's value (project over global) or the schema's default.
pub fn setting(key: &str) -> Option<String> {
+13
View File
@@ -74,6 +74,7 @@ struct State {
refreshed: Vec<String>,
view_messages: Vec<(String, String)>,
terminals: Vec<MockTerminal>,
locations: Vec<(String, u32, u32)>,
settings: BTreeMap<String, String>,
}
@@ -220,6 +221,10 @@ impl MockHost {
self.state.borrow().view_messages.clone()
}
pub fn opened_locations(&self) -> Vec<(String, u32, u32)> {
self.state.borrow().locations.clone()
}
/// Terminals the plugin opened.
pub fn terminals(&self) -> Vec<MockTerminal> {
self.state.borrow().terminals.clone()
@@ -595,6 +600,14 @@ pub mod current {
})
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
s.locations.push((path.into(), line, column));
Ok(())
})
}
pub fn setting(key: &str) -> Option<String> {
with(|s| s.settings.get(key).cloned())
}
+4
View File
@@ -133,6 +133,10 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
+1 -1
View File
@@ -5480,7 +5480,7 @@ export type PluginEvent =
/** For the script of a view's `html` body. */
{ type: "viewMessage"; pluginId: string; view: string; message: string } |
/** Open a terminal running `program` (already approved and verified). */
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } |
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } | { type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number } |
/**
* Plugin languages, or the servers they may start, changed: re-fetch
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
+4 -1
View File
@@ -12,7 +12,7 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import { registerStatusItem } from "@/workbench/contributions";
import { maybeEditorService, registerStatusItem } from "@/workbench/contributions";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { bumpView, deliverViewMessage } from "./views/viewStore";
@@ -259,6 +259,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
// The program was approved (`process:`) and verified by the host.
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
return true;
case "openLocation":
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
default:
return false;
}
+4
View File
@@ -133,6 +133,10 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them