Compare commits

..
Author SHA1 Message Date
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
l0ng-ai 28829ad756 style(vcs): format inline blame (#3051) 2026-11-13 11:22:44 +08:00
l0ng-ai d6c562049f test(e2e): load the app modules of the Branches resize spec through variables (#3050)
knip resolved the literal Vite paths in page.evaluate as imports and failed on them; the other specs pass the module path through a variable.
2026-11-13 11:21:04 +08:00
l0ng-ai 100075166c test(database): let FSEvents deliver the startup events before checking ignored files (#3048)
On macOS, FSEvents can report the creation of .openidea/ made just before the watch started after the watch is running, so ignores_other_files counted one change and failed on CI. The test now waits for those events and compares against the count after them.
2026-11-13 11:18:40 +08:00
l0ng-ai d5332a2197 feat(ui): show Go files with a gopher icon (#3047)
Go files get the gopher icon, as GoLand shows them, instead of a "GO" letter tile; go.mod and go.work keep theirs.
2026-11-13 11:16:32 +08:00
arookieofcandarookieofc b0d20d7997 feat(vcs): Backend trait for the Changes-view surface (#2958)
Adds an ide-vcs Backend trait for what the Commit / Local Changes view, diff viewer, annotate and log need from a repository (status, file_at / file_mode_at, diffs, line hunks, blame, block history, log, commit files, revision diffs, stage / unstage / discard, commit, pending changes), with Repo implementing it by forwarding. Git-only concepts (branches, remotes, stash, rebase, worktrees) stay on Repo, reached through as_git.

The trait has default no-op hooks for IDE file operations (files_added, files_deleted, file_moved), which the host now calls after create, copy, delete and rename so a checkout-based backend can open files for add / delete / move. The Changes-surface call sites in ide-host go through &dyn Backend. No behaviour change for Git.

Co-authored-by: arookieofc <[email protected]>
2026-11-13 11:09:57 +08:00
rohitkumardandaandrohitdanda 8df59cf5a7 fix(remote): decide routing, trust and accepted events on the desktop (#2999)
In a remote window the desktop took several decisions about itself from the server: it routed commands by the server's `commands` reply (a command the server left out ran locally), authorized its own commands from the server's `project_current().trusted`, and showed or ran whatever events the server forwarded, including any workbench action named by a plugin `executeCommand` event.

- Routing: commands go by `rpc::client_commands()`, the list a server of this build serves, compiled into the desktop; a test builds the server's dispatcher and compares the two.
- Trust: commands that run on the desktop follow the desktop's own decision, stored in `remote-project-trust.json` by target and root (a dev container also follows its local folder). The trust prompt appears unless the desktop's and the server's decisions agree; the answer and Revoke Trust update both. New commands `remote_trust_status` / `remote_trust_set`.
- Events: server `event`s pass an exhaustive allow-list (MCP approvals, update notices and window-group events are dropped); plugin `executeCommand` may only name `plugin.<id>.<command>`; `openProject` is dropped.
- Paths: a project root that the desktop would read as a share or device path is refused at connect.
- A remote plugin's `openUrl` and `copyToClipboard` ask first, showing the full address or the text.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 11:05:12 +08:00
Binandffgenius 3c68d2ea7f fix(vcs): position commit history above message input (#3040)
Commit Message History opened below its button, over the message field, and the menu reserved an empty icon column. It now opens above the Amend bar, aligned with the message's left edge, and Menu takes iconColumn={false} to leave out the icon column for rows without icons.

Co-authored-by: ffgenius <+[email protected]>
2026-11-13 10:54:03 +08:00
l0ng-ai cfe1416dbd fix(index): keep the symbol index across releases that leave extraction unchanged (#3027)
The persistent symbol index was reset whenever Syntax::fingerprint() changed, and the fingerprint included CARGO_PKG_VERSION, so every release reparsed every file on first open.

The fingerprint is now a build-time hash of what decides extraction: ide-language's src/ and queries/, languages/*.toml, and the locked tree-sitter crates. The index stamp also carries a build-time hash of ide-index's own sources (ignored state, size limit, stored record), so a change there still resets the index without a manual SCHEMA bump.
2026-11-13 10:47:24 +08:00
l0ng-ai 44fc0a2ebc fix(java): build with Maven when JDT's annotation processing recreates a file (#3043)
With MapStruct-Plus, JDT runs MapStruct again over the mappers MapStruct-Plus
generated in the same build and fails with "FilerException: Source file already
created", so every run of the application stopped on that error. javac doesn't,
so a Maven module whose JDT build fails this way is now built with Maven, and a
launch already built with Maven ignores that JDT error instead of reporting the
build as failing.
2026-11-13 10:44:29 +08:00
211 changed files with 1827 additions and 427 deletions

No files matched your search

+4 -1
View File
@@ -143,9 +143,12 @@ mod tests {
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
std::thread::sleep(Duration::from_secs(1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
assert_eq!(count.load(Ordering::SeqCst), seen);
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.6.0";
pub const ENGINE_VERSION: &str = "1.7.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+53 -10
View File
@@ -114,14 +114,28 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
// A checkout backend opens the file for add; Git's hook is a no-op.
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_vfs::create_file_bytes(&path, &data)?;
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
{
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
})
.await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
@@ -135,25 +149,54 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
let project = host.project(window).ok();
let history = project.as_ref().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
for p in &paths {
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
}
}
ide_workspace::file_ops::delete(&path)
ide_workspace::file_ops::delete(&path)?;
// A checkout backend opens the paths for delete; Git's hook is a no-op.
if let Some(p) = project {
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
}
Ok(())
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::copy(&from, &to)?;
// A copy is an add at the destination; Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
{
tracing::debug!("vcs files_added for {}: {e}", to.display());
}
Ok(())
})
.await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::rename(&from, &to)?;
// A checkout backend records the rename so history follows the file;
// Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
{
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
}
Ok(())
})
.await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+31 -16
View File
@@ -138,6 +138,11 @@ impl LogAccess {
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
}
}
/// A repository of the project (IDEA's VCS root).
@@ -280,6 +285,16 @@ fn per_repo(
Ok(())
}
/// `per_repo` through the backend-agnostic Changes-view surface.
fn per_backend(
project: &Project,
paths: &[PathBuf],
recorded: Option<&str>,
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
) -> IdeResult<()> {
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
}
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
let repos = project.repos();
@@ -348,7 +363,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
let project = host.project(window)?;
blocking(move || {
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
})
.await
}
@@ -360,7 +375,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
return project.with_log_backend_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
@@ -383,7 +398,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
}
external
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
@@ -403,7 +418,7 @@ pub async fn vcs_blame(
// An explicit text wins (merge view annotates its side panes, not the worktree).
blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
})
})
@@ -424,7 +439,7 @@ pub async fn vcs_block_history(
let cancel = host.begin_request(request);
let result = blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
})
})
@@ -441,7 +456,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
(r as &dyn Backend).log(&query, &cancel)
})
})
.await;
@@ -540,7 +555,7 @@ pub async fn vcs_commit_files(
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -551,7 +566,7 @@ pub async fn vcs_commits_changes(
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
@@ -567,7 +582,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Project-host operation (also registered with the remote dispatcher).
@@ -602,7 +617,7 @@ pub async fn vcs_add_to_ignore(
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
@@ -614,7 +629,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
pub async fn vcs_commit(
@@ -628,8 +643,8 @@ pub async fn vcs_commit(
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), None, |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
project.with_backend_recorded(repo.as_deref(), None, |r| {
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
.await
@@ -886,7 +901,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
.await
}
@@ -1201,7 +1216,7 @@ pub async fn vcs_diff_with_worktree(
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1213,7 +1228,7 @@ pub async fn vcs_diff_revisions(
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
+18 -4
View File
@@ -1276,12 +1276,13 @@ pub(super) fn commit_draft(
};
// Both callers have checked that the window's project is trusted.
r.set_execution_allowed(true);
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
let backend = &r as &dyn ide_vcs::Backend;
let pending = backend
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
.map_err(|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
})?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
@@ -1530,6 +1531,19 @@ impl HostApi for PluginApi {
}
Ok(())
}
fn open_diff(
&self,
client: &str,
diff: ide_plugin::ContentDiff,
file_name: Option<String>,
) -> Result<(), HostError> {
super::execution::validate_diff(&diff)?;
if let Some(name) = &file_name {
super::execution::label(name, 256)?;
}
self.shared.sink.emit(Some(client), PluginEvent::OpenDiff { plugin_id: self.id().into(), diff, file_name });
Ok(())
}
fn reply_content_tab(
&self,
client: &str,
+13 -8
View File
@@ -72,14 +72,19 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
return Err(HostError::Invalid("content status or error too long".into()));
}
if let Some(diff) = &reply.diff {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
validate_diff(diff)?;
}
Ok(())
}
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
Ok(())
}
+95 -3
View File
@@ -47,6 +47,48 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
pub const SECRET_WRITE: &str = "plugin_secret_write";
/// OAuth listeners bound on the desktop, never the project server.
pub const AUTH_REQUEST: &str = "plugin_auth";
/// What [`RemotePlugins::register`] adds to a server's commands.
pub const COMMANDS: &[&str] = &[
SYNC_PACKAGES,
"plugin_run_command",
"plugin_set_ui_locale",
"plugin_select_tests",
"plugin_parse_test_results",
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
"plugin_event_kinds",
"plugin_status_items",
"plugin_context_keys",
"plugin_cancel_task",
"plugin_answer_prompt",
"plugin_choose_program",
"plugin_program",
"plugin_answer_command",
"plugin_answer_commit",
"plugin_commit_checks",
"mcp_set_open_editors",
"mcp_set_focused_editor",
"mcp_report_problems",
"plugin_decoration_sets",
"plugin_set_active_file",
"plugin_view_render",
"plugin_view_event",
"plugin_service_detail",
"plugin_service_log",
"plugin_navigation_files",
"plugin_navigation_invalidate",
"plugin_navigation_index",
"plugin_navigation_goto",
"plugin_navigation_markers",
"plugin_endpoints",
"plugin_run_targets",
"plugin_answer_run_target",
"plugin_line_markers",
];
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretWrite {
@@ -134,20 +176,25 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
}
/// Whether a plugin event means something to a remote window (the desktop
/// owns the plugin list, themes and consent).
/// owns the plugin list, themes and consent). The desktop checks it again on
/// arrival: a server may send anything, and only what acts on the remote project
/// passes. A command may only be another plugin command, which runs on the server.
pub fn forwarded(event: &PluginEvent) -> bool {
if let PluginEvent::ExecuteCommand { id, .. } = event {
return plugin_command(id);
}
matches!(
event,
PluginEvent::RunTargetsChanged
| PluginEvent::WindowRequest { .. }
| PluginEvent::ContentTabReply { .. }
| PluginEvent::OpenDiff { .. }
| PluginEvent::DecorationsChanged { .. }
| PluginEvent::EditorOpen { .. }
| PluginEvent::EditorRanges { .. }
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
@@ -163,7 +210,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
@@ -173,6 +219,13 @@ pub fn forwarded(event: &PluginEvent) -> bool {
)
}
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
fn plugin_command(id: &str) -> bool {
id.strip_prefix("plugin.")
.and_then(|rest| rest.split_once('.'))
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
}
// ---- desktop side -----------------------------------------------------------
impl PluginHost {
@@ -951,4 +1004,43 @@ mod tests {
assert!(valid_id("ai-completion"));
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
}
fn execute(id: &str) -> PluginEvent {
PluginEvent::ExecuteCommand {
request_id: "1".into(),
id: id.into(),
arg: Some(r"\\host.example\share\pkg".into()),
chain: vec!["tools".into()],
}
}
#[test]
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
assert!(forwarded(&execute("plugin.tools.build")));
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
for id in [
"plugins.installFromDisk",
"project.revokeTrust",
"project.removeTrustedLocation",
"ssh.startSession",
"file.openProjectPath",
"terminal.runCommand",
"plugin.",
"plugin.tools",
"plugin..build",
"plugin.tools.",
] {
assert!(!forwarded(&execute(id)), "{id}");
}
}
#[test]
fn a_server_does_not_open_desktop_folders() {
let open = PluginEvent::OpenProject {
plugin_id: "tools".into(),
path: r"\\host.example\share\project".into(),
new_window: true,
};
assert!(!forwarded(&open));
}
}
+3
View File
@@ -218,6 +218,9 @@ pub enum PluginEvent {
/// Reply to a content action in this window.
#[serde(rename_all = "camelCase")]
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
/// Open two texts in this window's diff viewer.
#[serde(rename_all = "camelCase")]
OpenDiff { plugin_id: String, diff: ide_plugin::ContentDiff, file_name: Option<String> },
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
+41 -2
View File
@@ -8,7 +8,7 @@ use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
use ide_lsp::LspManager;
use ide_syntax::StructureService;
use ide_text::{AppliedChange, ByteEdit};
use ide_vcs::{Repo, Repos};
use ide_vcs::{Backend, Repo, Repos};
use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
@@ -971,6 +971,11 @@ impl Project {
}
}
/// `with_repo_of` through the backend-agnostic Changes-view surface.
pub fn with_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_of(path, |r| f(r as &dyn Backend))
}
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
if self.is_trusted() {
@@ -980,6 +985,40 @@ impl Project {
f(repo)
}
/// `with_repo_at` through the backend-agnostic Changes-view surface.
pub fn with_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(at, |r| f(r as &dyn Backend))
}
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
pub fn with_log_backend_at<T>(
&self,
at: Option<&Path>,
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
) -> IdeResult<T> {
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
}
/// `with_log_repo_of` through the backend-agnostic Changes-view surface.
pub fn with_log_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_log_repo_of(path, |r| f(r as &dyn Backend))
}
/// `with_repo` through the backend-agnostic Changes-view surface.
pub fn with_backend<T>(&self, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_backend_at(None, f)
}
/// `with_repo_recorded` through the backend-agnostic Changes-view surface.
pub fn with_backend_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
) -> IdeResult<T> {
self.with_repo_recorded(at, operation, |r| f(r as &dyn Backend))
}
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(None, f)
@@ -1004,7 +1043,7 @@ impl Project {
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
if let Ok(changed) = self.with_backend_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
tracing::debug!("local history: {e}");
+106 -3
View File
@@ -185,15 +185,15 @@ impl Dispatcher {
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
ops::fs::create_file_bytes(&c.host, &c.window, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy_merge", |c| async move {
ops::fs::copy_merge(
&c.host,
@@ -298,6 +298,56 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
Arc::new(PeerSink { peer, window })
}
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
/// groups) never comes from it.
pub fn client_accepts(event: &IdeEvent) -> bool {
match event {
IdeEvent::McpRequest { .. }
| IdeEvent::McpRequestClosed { .. }
| IdeEvent::WindowGroupsChanged
| IdeEvent::WindowFullscreenChanged
| IdeEvent::UpdateStatusChanged { .. }
| IdeEvent::UpdateAvailable { .. } => false,
IdeEvent::DbSourcesChanged
| IdeEvent::ProjectTrustChanged { .. }
| IdeEvent::FsChanged { .. }
| IdeEvent::ExcludedDirsChanged { .. }
| IdeEvent::DocPatched { .. }
| IdeEvent::DocDirty { .. }
| IdeEvent::WorkspaceEditApplied { .. }
| IdeEvent::DocLanguage { .. }
| IdeEvent::Diagnostics { .. }
| IdeEvent::Task { .. }
| IdeEvent::IndexState { .. }
| IdeEvent::VcsChanged { .. }
| IdeEvent::LanguageServer { .. }
| IdeEvent::LanguageServerWarning { .. }
| IdeEvent::LanguageServerRemoved { .. }
| IdeEvent::LspRefresh { .. }
| IdeEvent::TerminalExited { .. }
| IdeEvent::RunExited { .. }
| IdeEvent::Debug { .. }
| IdeEvent::Notify { .. }
| IdeEvent::SettingsChanged { .. }
| IdeEvent::ProjectRootsChanged { .. }
| IdeEvent::Askpass { .. }
| IdeEvent::AskpassDone { .. }
| IdeEvent::NotebookKernel { .. } => true,
}
}
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
pub fn client_commands() -> Vec<&'static str> {
let mut out = Dispatcher::standard().commands();
out.extend(crate::plugins::remote_host::COMMANDS);
out.push(crate::plugins::remote::SYNC_COMMAND);
out.sort_unstable();
out.dedup();
out
}
struct PeerSink {
peer: Arc<OnceLock<Peer>>,
window: Option<String>,
@@ -364,3 +414,56 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
other => other.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
#[test]
fn a_server_cannot_raise_desktop_prompts() {
let confirm = McpUiRequest::Confirm {
client: "agent".into(),
tool: "run".into(),
subject: "rm -rf".into(),
project: "/srv/app".into(),
detail: None,
};
let update = ide_core::updates::UpdateInfo {
version: "9.9.9".into(),
current_version: "0.1.0".into(),
notes: None,
date: None,
};
for event in [
IdeEvent::McpRequest { id: 1, request: confirm },
IdeEvent::McpRequestClosed { id: 1 },
IdeEvent::UpdateAvailable { update },
IdeEvent::UpdateStatusChanged {
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
},
IdeEvent::WindowGroupsChanged,
IdeEvent::WindowFullscreenChanged,
] {
assert!(!client_accepts(&event), "{event:?}");
}
}
#[test]
fn a_server_reports_its_project_to_the_desktop() {
for event in [
IdeEvent::FsChanged {
created: vec!["/srv/app/a".into()],
modified: vec![],
removed: vec![],
ignore_rules: vec![],
},
IdeEvent::VcsChanged { root: "/srv/app".into() },
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
] {
assert!(client_accepts(&event), "{event:?}");
}
}
}
+57 -2
View File
@@ -18,6 +18,15 @@ pub struct TrustStore {
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
remote_file: PathBuf,
remote: Mutex<IdeResult<RemoteDecisions>>,
}
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
#[derive(Clone, Default, Serialize, Deserialize)]
struct RemoteDecisions {
trusted: Vec<String>,
restricted: Vec<String>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
@@ -40,7 +49,37 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
let remote_file = data.join("remote-project-trust.json");
let remote = match std::fs::read(&remote_file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
}
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
/// Commands a remote window runs here follow it, not the server's own trust.
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
let remote = self.remote.lock().clone()?;
Ok(if remote.restricted.iter().any(|l| l == location) {
Some(false)
} else if remote.trusted.iter().any(|l| l == location) {
Some(true)
} else {
None
})
}
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
let mut state = self.remote.lock();
let mut next = state.clone()?;
next.trusted.retain(|l| l != location);
next.restricted.retain(|l| l != location);
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
persist(&self.remote_file, &next)?;
*state = Ok(next);
Ok(())
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -133,7 +172,7 @@ impl TrustStore {
}
}
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -372,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_trust_status"
| "remote_trust_set"
| "remote_bootstrap_list"
| "wsl_distros"
| "wsl_resolve"
@@ -526,4 +567,18 @@ mod tests {
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let store = load();
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
assert_eq!(store.remote_decision(here).unwrap(), None);
store.decide_remote(here, true).unwrap();
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
store.decide_remote(here, false).unwrap();
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
}
}
+20
View File
@@ -6352,6 +6352,26 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn a_plugin_command_opens_a_diff_in_its_window() {
let f = execution_fixture();
f.take_events();
f.run("execution", "diff", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(eventually(|| {
f.events.lock().iter().any(|(client, event)| {
client.as_deref() == Some("main")
&& matches!(event, PluginEvent::OpenDiff { plugin_id, diff, file_name }
if plugin_id == "execution"
&& diff.left == "before\n"
&& diff.right == "after\n"
&& diff.title.as_deref() == Some("Review")
&& file_name.as_deref() == Some("src/review.rs"))
})
}));
}
#[test]
fn api14_fixture_exports_test_selection() {
let f = fixture(Limits::default());
+26
View File
@@ -455,3 +455,29 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
// As `openidea-server` assembles its commands.
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
assert_eq!(rpc::client_commands(), dispatcher.commands());
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
+2 -2
View File
@@ -83,10 +83,10 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
ops::fs::rename(&host, "main", original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
ops::fs::copy(&host, "main", renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
+3
View File
@@ -21,6 +21,9 @@ ignore.workspace = true
globset.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
tempfile.workspace = true
criterion = { version = "0.5", default-features = false }
+33
View File
@@ -0,0 +1,33 @@
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
//! file (ignored state, size limit, record layout) is decided here as well as in
//! `ide-language`, so the persistent index is rebuilt when either changes.
use std::path::{Path, PathBuf};
fn main() {
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
println!("cargo:rerun-if-changed={}", src.display());
let mut files = Vec::new();
collect(&src, &mut files);
files.sort();
let mut h = blake3::Hasher::new();
for file in files {
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ impl Store {
}
};
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
+3
View File
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
+61
View File
@@ -0,0 +1,61 @@
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
//! sources, queries, language definitions and the locked tree-sitter crates)
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
//! extraction untouched.
use std::path::{Path, PathBuf};
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace = manifest.join("../..");
let lock = workspace.join("Cargo.lock");
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
let mut h = blake3::Hasher::new();
for dir in &inputs {
println!("cargo:rerun-if-changed={}", dir.display());
let mut files = Vec::new();
collect(dir, &mut files);
files.sort();
for file in files {
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
}
println!("cargo:rerun-if-changed={}", lock.display());
match std::fs::read_to_string(&lock) {
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
// Built outside the workspace: fall back to the crate version.
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
};
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
/// `name version checksum` of every locked `tree-sitter*` package.
fn tree_sitter_packages(lock: &str) -> String {
let mut out = String::new();
for package in lock.split("[[package]]") {
let field = |key: &str| {
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
};
if field("name").starts_with("tree-sitter") {
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
}
}
out
}
+4 -22
View File
@@ -132,29 +132,11 @@ impl Syntax {
SPECS.iter().map(|s| s.name)
}
/// Changes whenever extraction output may change (grammar set, queries,
/// crate version); persistent caches of [`Syntax::tags`] key on it.
/// Changes whenever extraction output may change (this crate's sources,
/// queries, language definitions, tree-sitter crate versions; see
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
pub fn fingerprint(&self) -> String {
static FP: OnceLock<String> = OnceLock::new();
FP.get_or_init(|| {
let mut h = blake3::Hasher::new();
h.update(env!("CARGO_PKG_VERSION").as_bytes());
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
h.update(b"web-definitions-1");
for s in SPECS {
h.update(s.name.as_bytes());
h.update(&Language::new(s.language).abi_version().to_le_bytes());
h.update(s.outline.as_bytes());
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
for k in list {
h.update(k.as_bytes());
h.update(b"\0");
}
}
}
h.finalize().to_hex()[..16].to_owned()
})
.clone()
env!("SYNTAX_SOURCE_HASH").to_owned()
}
/// Whether a tree-sitter grammar is bundled for this language.
+6 -1
View File
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.6";
pub const API_VERSION: &str = "1.7";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -170,6 +170,11 @@ pub trait HostApi: Send + Sync {
let _ = (client, tab, cancel, timeout);
Err(HostError::Failed("content tabs unavailable".into()))
}
/// Opens a diff of two texts in the invoking window.
fn open_diff(&self, client: &str, diff: crate::ContentDiff, file_name: Option<String>) -> Result<(), HostError> {
let _ = (client, diff, file_name);
Err(HostError::Failed("diffs unavailable".into()))
}
/// Answers a content action in the invoking window.
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
let _ = (client, request, reply);
+6
View File
@@ -1770,6 +1770,12 @@ impl views::Host for StoreData {
)
.map_err(to_wit))
}
fn open_diff(&mut self, diff: views::ContentDiff, file_name: Option<String>) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self.host.open_diff(self.invocation.client.as_deref().unwrap(), diff.into(), file_name).map_err(to_wit))
}
fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
+5 -5
View File
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.6.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.0.0-rc.1"] {
let host = v("1.7.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.7.0"), &host).unwrap_err();
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.6 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Binary file not shown.
@@ -1 +1 @@
005d9da29eb7d713a97ff872051e408f653030434d837835843298553c9e12e0
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
Binary file not shown.
@@ -1 +1 @@
6359ef4271f38374889412bd926d9aa735f59a8ce047ffcb06c47976444196cf
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
Binary file not shown.
@@ -1 +1 @@
6f560f3d0d7737150d2dfa09c3b801bd3b4c3a52cef9893ed8fc045809fe33e1
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
Binary file not shown.
@@ -1 +1 @@
4024af2d55d7fe4e8c456efd7e0c497ade4b2c5959e9108ff218f4a669e920f4
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
Binary file not shown.
@@ -1 +1 @@
4ca977f53a18e9e8f869d34e027a15abece9e97b9331e23d4f71af721b104611
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
Binary file not shown.
@@ -1 +1 @@
3d12a00f81e0db1b0902c2ddebfd6454a056f15b97add80b22a0333722568dc8
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
Binary file not shown.
@@ -1 +1 @@
95d89e411216f50d0b34bee110c6bfc9b8fbcb2ffaae2e912c3609f7bf678b2c
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
@@ -40,6 +40,10 @@ title = "write"
id = "content"
title = "content"
[[contributes.commands]]
id = "diff"
title = "diff"
[[contributes.commands]]
id = "events"
title = "events"
+12
View File
@@ -78,6 +78,18 @@ impl Plugin for Execution {
})?;
Ok(None)
}
"diff" => {
host::open_diff(
&ContentDiff {
left: "before\n".into(),
right: "after\n".into(),
title: Some("Review".into()),
..Default::default()
},
Some("src/review.rs"),
)?;
Ok(None)
}
"events" => host::storage::get("events"),
"validation" => host::storage::get("validation"),
_ => Err(Error::NotFound(id.into())),
Binary file not shown.
@@ -1 +1 @@
dd4b6c5153892a4f3facb3f23cb1d92da39a3977985b8abf4eaa9558decfa4a9
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
Binary file not shown.
@@ -1 +1 @@
325bc15cd28933d9687bc3c316008f921f48a4a69d7528c6b367fa0ff22489b8
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
Binary file not shown.
@@ -1 +1 @@
abe706392e04ef5a14f9feab88e0c0d6a9e96bee7de771b61deeb65e7d9e0a7c
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
Binary file not shown.
@@ -1 +1 @@
fb3677946021b30d226e2e3d422c352645c2065c93057c0a645246520860fc90
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
Binary file not shown.
@@ -1 +1 @@
a01852ead9004ac1558028da19a1ae96ccd035775484089fd39f14263082daf1
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
Binary file not shown.
@@ -1 +1 @@
bf00050b7bb9911680a94fd36aaeb83fc96d94030e8ceef51d1723c3950253f9
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
Binary file not shown.
@@ -1 +1 @@
d00a07d9d51e7ca117cec59070d2812d4d1efb2730b60a6f171c8fd12903d055
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
Binary file not shown.
@@ -1 +1 @@
0aa0ee56e8bacd4d218bafecdec7f4b6249381fad6e31f8b2ee89fd88b16d710
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
Binary file not shown.
@@ -1 +1 @@
e06955818969808e6695fba9178727239e343bc4542efbf56262dd407175fd8a
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
@@ -1 +1 @@
7af67673471d49f97aa2cc131b5deddfc78ee7fc203901eba12f4970a5b13f75
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
Binary file not shown.
@@ -1 +1 @@
e81d2454a61977ab69dc8b5c3c617627c559c4e340337a3d43309daeb9b1c12d
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
+3 -3
View File
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.6.0;
export openidea:plugin/providers@1.6.0;
export openidea:plugin/file-actions@1.6.0;
include openidea:plugin/plugin-syntax@1.7.0;
export openidea:plugin/providers@1.7.0;
export openidea:plugin/file-actions@1.7.0;
}
",
path: "../../../../../wit",
Binary file not shown.
@@ -1 +1 @@
123ae68df6baa3b5983349ba05fb441ec67fddcb32cab566b0d02e443bb4800e
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
Binary file not shown.
@@ -1 +1 @@
7b26a4ec36b496f9a4f5e029ef557707961a281e31508863c989a5a5abd5e225
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
Binary file not shown.
@@ -1 +1 @@
67f278ade12d11b1445ac3d448853ec46282f2f3e2b2299c6c8fe0dfb4fc86cd
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
Binary file not shown.
@@ -1 +1 @@
8cf9210844a1968f485e6e747f6dff4a1fdf0a628e2945f731d8289dd01f8385
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
Binary file not shown.
@@ -1 +1 @@
263e6ab4b54593f09ae2cf71e9a191acb95ed744cc16bbaf9a9263e41614e99a
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
Binary file not shown.
@@ -1 +1 @@
682a712b6c75ad0c0da24476f87c77a76b248c201d4d31923a79fda053b76b90
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
Binary file not shown.
@@ -1 +1 @@
c5718b3978f21291cfff7a00233ba68d545a5977bc3e90c546b4380528295446
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
Binary file not shown.
@@ -1 +1 @@
186b30c785fae23d18c5efe8fa63f6526d23d9d141580deef7ee7009310a88c3
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
Binary file not shown.
@@ -1 +1 @@
cb3a1c4a0518aa3f3e689337e8f4249180e5b3753b0e7bfaba8c974f89f6ef7a
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
Binary file not shown.
@@ -1 +1 @@
fa97682ca30cb0ad1392ff1bab4d99ea6d9ccb2654feedfdc130d11adb7df93b
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
+302
View File
@@ -0,0 +1,302 @@
//! The version-control backend abstraction (#743).
//!
//! `ide-vcs` grew up Git-only: [`Repo`] talks to git through gitoxide and the
//! `git` CLI. A second backend (Perforce first, maybe SVN later) must serve the
//! same UI — the Commit / Local Changes tool window, the diff viewer, the log
//! and Annotate — without that UI learning backend specifics.
//!
//! [`Backend`] is that surface: status of the working area, the text and mode
//! of a file at a revision, unified diffs, line hunks and annotate, the change
//! log, and the writes a user makes from the Changes view — stage ("open for
//! edit/add" in Perforce), discard ("revert") and commit ("submit").
//!
//! Deliberately Git-only concepts stay on [`Repo`]: branches and remotes,
//! stash, rebase/merge/cherry-pick, tags, worktrees, the index's staged /
//! unstaged split beyond what [`FileStatus`] already carries, and Restricted
//! Mode. A caller that needs them downcasts through [`Backend::as_git`]; a
//! caller that must work for every backend uses only the trait. As Perforce
//! support lands, methods that turn out to be needed generically move here.
use crate::{
BlameLine, BlockHistory, ChangedFile, Commit, CommitOptions, FileStatus, LineHunk, LogQuery, PendingChange, Repo,
RevisionChange, TextEncoding,
};
use ide_core::{CancellationToken, IdeResult};
use std::path::{Path, PathBuf};
/// The version-control surface the Changes view, diff viewer, annotate and log
/// need from a repository. Implemented by every supported backend; today that
/// is Git via [`Repo`].
///
/// Revision strings follow the backend's own syntax: git revisions (`HEAD`,
/// `:0` for the index, ids, `<id>^`…) for the Git backend; a Perforce backend
/// takes `#have`, `@label` or a change number, and its "index" side is the
/// opened-file state.
pub trait Backend: Send + Sync {
/// The repository's working root.
fn root(&self) -> &Path;
/// Working-area changes, in the same shape `git status` gives: staged and
/// unstaged kinds per path. A backend without an index reports everything
/// as `unstaged`.
fn status(&self) -> IdeResult<Vec<FileStatus>>;
/// Text of `path` at `rev`; `None` when absent there.
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
/// The file's mode at `rev` in the backend's own notation (git's `100644`,
/// `100755`, `120000`…; a Perforce backend reports the filetype, where
/// `+x` is the executable case). `None` when absent there. The diff viewer
/// shows "File mode changed" when the texts compare equal but the modes
/// differ. `rev` follows [`file_at`](Self::file_at)'s rules, `WT` reads the
/// worktree.
fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>>;
/// Unified diff text of `path`'s working change (against the depot/HEAD
/// side), empty when unchanged.
fn diff_text(&self, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String>;
/// Working tree against `against` (or the base revision) as a patch,
/// optionally restricted to a file.
fn working_tree_patch(
&self,
path: Option<&Path>,
against: Option<&str>,
max_bytes: usize,
cancel: &CancellationToken,
) -> IdeResult<String>;
/// Gutter markers: `current_text`'s hunks against the base.
fn line_hunks(
&self,
path: &Path,
current_text: &str,
against_index: bool,
encoding: TextEncoding,
) -> IdeResult<Vec<LineHunk>>;
/// Annotate: who last changed each line of `path` (`text` when given is the
/// buffer, not the disk file).
fn blame(
&self,
path: &Path,
text: Option<&str>,
revision: Option<&str>,
encoding: TextEncoding,
) -> IdeResult<Vec<BlameLine>>;
/// History for Selection: the changes that touched `lines` (0-based) of
/// `text` (the buffer, or the disk file when `None`).
fn block_history(
&self,
path: &Path,
text: Option<&str>,
lines: (u32, u32),
encoding: TextEncoding,
cancel: &CancellationToken,
) -> IdeResult<BlockHistory>;
/// The change log, newest first.
fn log(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>>;
/// Files `commit` changed.
fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>>;
/// The per-file changes a set of commits made, for a combined diff.
fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>>;
/// Changes between two revisions.
fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>>;
/// Files differing between `rev` and the worktree.
fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>>;
/// Per-file line counts of `diff_with_worktree`.
fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>>;
// ---- working-area writes ----
/// Marks `paths` for commit: `git add`, `p4 edit`/`p4 add`. For a file a
/// backend stores read-only until opened, this is checkout-on-edit too.
fn stage(&self, paths: &[PathBuf]) -> IdeResult<()>;
/// Reverses [`stage`](Self::stage) without touching the worktree
/// (`git restore --staged`, `p4 revert -k` equivalent).
fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()>;
/// Discards working-area changes: tracked paths return to the base,
/// untracked ones are deleted (`git restore`/`clean`, `p4 revert`).
fn discard(&self, paths: &[PathBuf]) -> IdeResult<()>;
/// Records the staged/opened changes as a new change (`git commit`,
/// `p4 submit`); returns its id. `encoding` resolves each file's text
/// encoding (the caller knows the open documents' settings).
fn commit(
&self,
message: &str,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<String>;
/// What [`commit`](Self::commit) would record, without committing.
fn pending_changes(
&self,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<PendingChange>>;
// ---- IDE file operations ----
/// Files or folders the IDE created (project tree New File, refactorings).
/// A checkout backend opens them for add (`p4 add`); Git tracks new files
/// by content, so this is a no-op. Called after the files exist on disk;
/// a failure leaves them as ordinary untracked files.
fn files_added(&self, _paths: &[PathBuf]) -> IdeResult<()> {
Ok(())
}
/// Files or folders the IDE deleted (project tree Delete, refactorings).
/// A checkout backend opens them for delete (`p4 delete`); Git records the
/// deletion at commit time, so this is a no-op. Called after they are gone
/// from disk.
fn files_deleted(&self, _paths: &[PathBuf]) -> IdeResult<()> {
Ok(())
}
/// A file or folder the IDE moved or renamed (project tree Rename/Move,
/// refactorings). A checkout backend records the rename (`p4 move`) so
/// history follows the file; Git detects renames from content, so this is
/// a no-op. Called after `from` is at `to` on disk.
fn file_moved(&self, _from: &Path, _to: &Path) -> IdeResult<()> {
Ok(())
}
/// The concrete Git repository, when this backend is one — for the
/// Git-only surface (branches, remotes, stash, rebase…). `None` for other
/// backends; callers should degrade to hiding Git-only UI.
fn as_git(&self) -> Option<&Repo> {
None
}
}
impl Backend for Repo {
fn root(&self) -> &Path {
Repo::root(self)
}
fn status(&self) -> IdeResult<Vec<FileStatus>> {
Repo::status(self)
}
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
Repo::file_at(self, path, rev, encoding)
}
fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
Repo::file_mode_at(self, path, rev)
}
fn diff_text(&self, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String> {
Repo::diff_text(self, path, staged, encoding)
}
fn working_tree_patch(
&self,
path: Option<&Path>,
against: Option<&str>,
max_bytes: usize,
cancel: &CancellationToken,
) -> IdeResult<String> {
Repo::working_tree_patch(self, path, against, max_bytes, cancel)
}
fn line_hunks(
&self,
path: &Path,
current_text: &str,
against_index: bool,
encoding: TextEncoding,
) -> IdeResult<Vec<LineHunk>> {
Repo::line_hunks(self, path, current_text, against_index, encoding)
}
fn blame(
&self,
path: &Path,
text: Option<&str>,
revision: Option<&str>,
encoding: TextEncoding,
) -> IdeResult<Vec<BlameLine>> {
Repo::blame(self, path, text, revision, encoding)
}
fn block_history(
&self,
path: &Path,
text: Option<&str>,
lines: (u32, u32),
encoding: TextEncoding,
cancel: &CancellationToken,
) -> IdeResult<BlockHistory> {
Repo::block_history(self, path, text, lines, encoding, cancel)
}
fn log(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
Repo::log_cancellable(self, query, cancel)
}
fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
Repo::commit_files(self, commit)
}
fn commits_changes(&self, commits: &[String]) -> IdeResult<Vec<RevisionChange>> {
Repo::commits_changes(self, commits)
}
fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
Repo::diff_revisions(self, from, to)
}
fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
Repo::diff_with_worktree(self, rev)
}
fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
Repo::numstat_with_worktree(self, rev)
}
fn stage(&self, paths: &[PathBuf]) -> IdeResult<()> {
Repo::stage(self, paths)
}
fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()> {
Repo::unstage(self, paths)
}
fn discard(&self, paths: &[PathBuf]) -> IdeResult<()> {
Repo::discard(self, paths)
}
fn commit(
&self,
message: &str,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<String> {
Repo::commit_with(self, message, options, encoding)
}
fn pending_changes(
&self,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<PendingChange>> {
Repo::pending_changes(self, options, max_bytes, encoding)
}
fn as_git(&self) -> Option<&Repo> {
Some(self)
}
}
+2
View File
@@ -610,6 +610,7 @@ pub struct ChangelistState {
pub files: std::collections::BTreeMap<String, String>,
}
mod backend;
mod bek;
mod blame;
mod block_history;
@@ -637,6 +638,7 @@ pub use index_writes::is_own_index_write;
mod text_encoding;
mod write;
pub use backend::Backend;
pub use cli::{
GitCommandObserver, detected_git_executable, filter_overrides_from, git_version, restrict, set_askpass_env,
set_git_command_observer, set_git_executable,
+5 -2
View File
@@ -1,7 +1,7 @@
//! The repositories of a project (IDEA's VCS roots): the one containing the project
//! root plus those nested below it (separate clones side by side, submodules).
use crate::{FileStatus, Repo};
use crate::{Backend, FileStatus, Repo};
use ide_core::IdeResult;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -142,6 +142,9 @@ impl Repos {
/// Changes of every repository. A nested repository reports its own files, so the
/// enclosing one's "untracked folder" entry for it is dropped. A repository that cannot
/// be read (e.g. deleted since the last scan) is left out; fails only when all fail.
///
/// The reads go through [`Backend`], the Changes-view surface — repositories of a
/// future second backend slot in here once `Repos` learns to hold them.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
let mut first_error = None;
@@ -153,7 +156,7 @@ impl Repos {
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
let status = match repo.status() {
let status = match (repo.as_ref() as &dyn Backend).status() {
Ok(status) => {
read += 1;
status
@@ -19,7 +19,7 @@ The plugin host runs **where the project is**: in the desktop app for local proj
## The interface
The API is defined in WIT, in `wit/` (package `openidea:plugin@1.6.0`). A plugin is a component targeting the `plugin` world (or `plugin-syntax`). The guest SDK `sdk/rust/openidea-plugin` wraps it in a typed Rust API and carries a copy of `wit/`; `crates/ide-plugin/tests/api_compat.rs` checks the copies are identical.
The API is defined in WIT, in `wit/` (package `openidea:plugin@1.7.0`). A plugin is a component targeting the `plugin` world (or `plugin-syntax`). The guest SDK `sdk/rust/openidea-plugin` wraps it in a typed Rust API and carries a copy of `wit/`; `crates/ide-plugin/tests/api_compat.rs` checks the copies are identical.
Released API versions are frozen. wasmtime resolves interface names by semver, but function types must match exactly, so a released 1.x only grows: new interfaces and new optional exports, never changed records or variants. See _Evolving the plugin API_ in `plugins/README.md` before touching `wit/`.
+1 -1
View File
@@ -3,7 +3,7 @@ title: "API reference"
description: "Every interface of the openidea:plugin WIT package, with a one-line summary and a link to its source."
---
The plugin API is the WIT package `openidea:plugin@1.6.0` in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit). The WIT files are the reference: each interface, type and function is documented in comments there. 1.6.0 is the version on `main`; the latest released version is 1.4.0, whose WIT is kept in [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released).
The plugin API is the WIT package `openidea:plugin@1.7.0` in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit). The WIT files are the reference: each interface, type and function is documented in comments there. 1.7.0 is the version on `main`; the latest released version is 1.6.0, whose WIT is kept in [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released).
<Info>
A generated API reference is planned. Until then, use the WIT files below, `cargo doc` for the Rust SDK
+1 -1
View File
@@ -9,7 +9,7 @@ A code plugin is a WebAssembly **component** (component model, WASI preview 2) t
| Part | Where | Role |
| ---------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Plugin API | [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) | The contract: the WIT package `openidea:plugin@1.6.0`, its interfaces and worlds |
| Plugin API | [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) | The contract: the WIT package `openidea:plugin@1.7.0`, its interfaces and worlds |
| Rust SDK | [`sdk/rust/openidea-plugin`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin) | Typed wrappers over the WIT, the `Plugin` trait, `register_plugin!`, and a mock host for `cargo test` |
| Packages | [`crates/ide-extension`](https://github.com/l0ng-ai/openidea/tree/main/crates/ide-extension) | The manifest, capabilities and their risk, the grant ledger, contribution points, `.oiplugin` packages and the marketplace index |
| Runtime | [`crates/ide-plugin`](https://github.com/l0ng-ai/openidea/tree/main/crates/ide-plugin) | The wasmtime engine, one `Store` per plugin, limits, and one actor thread per active plugin |
+11 -9
View File
@@ -281,6 +281,7 @@ With `engines.openidea = "^1.3"`, a page can send JSON messages through
| `pty-spawn`, `pty-input`, `pty-resize`, `pty-kill`, `pty-ack` | An interactive PTY owned by the frame; output arrives as `pty-data`, `pty-exit`, `pty-error`. At most 8 sessions per plugin. |
| `open-log-tab` | A read-only editor tab following a program's output, with argument toggles. |
| `open-content-tab` | An editor tab on content the plugin supplies, with toolbar actions answered by `content-tab-reply`. |
| `open-diff` | API 1.7: the diff viewer on two texts (`left`, `right`, optional labels, `title` and `fileName` for highlighting). |
The message fields and limits are in the
[plugin guide](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md#web-frame-ptys-api-13);
@@ -338,15 +339,16 @@ divider. The host asks after the item's `select` event and again after `refresh_
These are called from a command or event of a window:
| Function | Opens |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host::open_location(path, line, column)` | A project file in an editor, caret at the position (`project.read`; a file in the plugin's private directory also works). |
| `host::open_editor(path, selection)` | API 1.4: open a project file and select a range. |
| `host::open_terminal(title, program, args)` | A Terminal tab running an approved `process:<program>` in the project root. |
| `host::open_terminal_session(key, title, &cmd)` | API 1.4: a keyed terminal; reopening the key focuses it. Write to it with `host::write_terminal(key, text)`; its exit arrives as a `terminal-exited` event. |
| `host::open_log_tab(key, title, &log)` | API 1.3: the read-only log tab, from a `ProcessLog`. At most 32 per window; not yet in remote projects. |
| `host::open_content_tab(&ContentTab { .. })` | API 1.4: a content editor tab without a web view; actions arrive as `content-tab-action` events, answered with `host::reply_content_tab`. |
| `host::output_append(channel, text)` | API 1.4: a read-only **Output** tab per channel (32 channels, 1 MiB each); `output_clear`, `output_reveal`. |
| Function | Opens |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host::open_location(path, line, column)` | A project file in an editor, caret at the position (`project.read`; a file in the plugin's private directory also works). |
| `host::open_editor(path, selection)` | API 1.4: open a project file and select a range. |
| `host::open_terminal(title, program, args)` | A Terminal tab running an approved `process:<program>` in the project root. |
| `host::open_terminal_session(key, title, &cmd)` | API 1.4: a keyed terminal; reopening the key focuses it. Write to it with `host::write_terminal(key, text)`; its exit arrives as a `terminal-exited` event. |
| `host::open_log_tab(key, title, &log)` | API 1.3: the read-only log tab, from a `ProcessLog`. At most 32 per window; not yet in remote projects. |
| `host::open_content_tab(&ContentTab { .. })` | API 1.4: a content editor tab without a web view; actions arrive as `content-tab-action` events, answered with `host::reply_content_tab`. |
| `host::open_diff(&ContentDiff { .. }, file_name)` | API 1.7: the diff viewer on two texts, such as a review's file before and after; `file_name` picks the syntax highlighting. |
| `host::output_append(channel, text)` | API 1.4: a read-only **Output** tab per channel (32 channels, 1 MiB each); `output_clear`, `output_reveal`. |
```rust
use openidea_plugin::{ContentAction, ContentTab, host};
+2 -2
View File
@@ -36,8 +36,8 @@ Code plugins are WebAssembly components targeting the `plugin` world in [`wit/`]
| | |
| ------------------------- | ------------------------------------------------------------ |
| Latest released API | **1.4.0**, shipped with OpenIDEA 0.1.40 |
| In development | **1.6.0** on the `main` branch, planned for the next release |
| Latest released API | **1.6.0**, shipped with OpenIDEA 0.1.43 |
| In development | **1.7.0** on the `main` branch, planned for the next release |
| Format of the WIT package | `openidea:plugin@<version>` |
Declare the API your plugin needs with `engines.openidea` in the manifest, for example `engines.openidea = "^1.4"`.
+11 -10
View File
@@ -3,7 +3,7 @@ title: "API versions and compatibility"
description: "engines.openidea, the plugin API versions from 1.0 to 1.6, and the rules that keep released plugins loading in newer IDEs."
---
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.6.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.7.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
<Note>
The plugin system is a preview. The compatibility rules below hold for released 1.x versions; what is still unreleased
@@ -12,15 +12,16 @@ The plugin API has one version number: the version of the WIT package in [`wit/`
## Versions
| API | Shipped with | Highlights |
| ----- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | OpenIDEA 0.1.36 | The base API: commands, events, storage, tasks, project and editor access, VCS, diagnostics, network and secrets, processes, declarative views and custom editors, settings, editor providers, syntax provider |
| 1.1.0 | OpenIDEA 0.1.37 | `vcs.remotes`, `ui.copy-to-clipboard`, `ui.open-url`, opening a project file at a line from a view |
| 1.2.0 | OpenIDEA 0.1.38 | Background document indexers, pickers, input boxes and confirmations, plugin context keys for `when` clauses, the files selected in the Project view, a manifest `icon`, the `viewItem.context` menu |
| 1.3.0 | OpenIDEA 0.1.39 | Commit message and before-commit checks, Services detail panes, diagnostics while typing, hover, inlay hints, semantic tokens, file-changing code actions, HTTP endpoints, files outside the project (`fs.read`/`fs.write`, `project.write`, private plugin directory), batched `files-changed` events and path filters, workspace symbols, long-running processes, notifications with buttons, translations, multi-page settings, file template variables, web view PTYs, log tabs and content tabs |
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`, `ipc:`, `ide.commands` and `database.sources` capabilities; editor and project snapshots, decorations, text search, Problems, Git log/blame/diff; output channels; OAuth loopback; runs, terminal sessions, run configurations, test result parsing and run targets; formatting and other language features; embedded languages; snippets; more menu locations; database sources; MCP tools |
| 1.5.0 | Planned for the next release | `Project::notify` (a notification in one project's window), the `net:ask` capability, run target validation, per-command run target working-directory arguments |
| 1.6.0 | In development on `main` | Bounded, project-bound background WebSockets for event-stream plugins |
| API | Shipped with | Highlights |
| ----- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | OpenIDEA 0.1.36 | The base API: commands, events, storage, tasks, project and editor access, VCS, diagnostics, network and secrets, processes, declarative views and custom editors, settings, editor providers, syntax provider |
| 1.1.0 | OpenIDEA 0.1.37 | `vcs.remotes`, `ui.copy-to-clipboard`, `ui.open-url`, opening a project file at a line from a view |
| 1.2.0 | OpenIDEA 0.1.38 | Background document indexers, pickers, input boxes and confirmations, plugin context keys for `when` clauses, the files selected in the Project view, a manifest `icon`, the `viewItem.context` menu |
| 1.3.0 | OpenIDEA 0.1.39 | Commit message and before-commit checks, Services detail panes, diagnostics while typing, hover, inlay hints, semantic tokens, file-changing code actions, HTTP endpoints, files outside the project (`fs.read`/`fs.write`, `project.write`, private plugin directory), batched `files-changed` events and path filters, workspace symbols, long-running processes, notifications with buttons, translations, multi-page settings, file template variables, web view PTYs, log tabs and content tabs |
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`, `ipc:`, `ide.commands` and `database.sources` capabilities; editor and project snapshots, decorations, text search, Problems, Git log/blame/diff; output channels; OAuth loopback; runs, terminal sessions, run configurations, test result parsing and run targets; formatting and other language features; embedded languages; snippets; more menu locations; database sources; MCP tools |
| 1.5.0 | OpenIDEA 0.1.41 | `Project::notify` (a notification in one project's window), the `net:ask` capability, run target validation, per-command run target working-directory arguments |
| 1.6.0 | OpenIDEA 0.1.43 | Bounded, project-bound background WebSockets for event-stream plugins |
| 1.7.0 | In development on `main` | `views.open-diff`: two texts in the diff viewer, such as a review's file before and after |
The full list of what each version added is in [`plugins/README.md`](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md).
@@ -19,7 +19,7 @@ description: "插件系统的内部实现:扩展内核、每个插件一个 ac
## 接口
API 用 WIT 定义,位于 `wit/`(包名 `openidea:plugin@1.6.0`)。插件是面向 `plugin` world(或 `plugin-syntax`)的组件。插件端 SDK `sdk/rust/openidea-plugin` 把它封装成类型化的 Rust API,并带有一份 `wit/` 副本;`crates/ide-plugin/tests/api_compat.rs` 检查两份是否一致。
API 用 WIT 定义,位于 `wit/`(包名 `openidea:plugin@1.7.0`)。插件是面向 `plugin` world(或 `plugin-syntax`)的组件。插件端 SDK `sdk/rust/openidea-plugin` 把它封装成类型化的 Rust API,并带有一份 `wit/` 副本;`crates/ide-plugin/tests/api_compat.rs` 检查两份是否一致。
已发布的 API 版本是冻结的。wasmtime 按 semver 解析接口名,但函数类型必须完全一致,所以已发布的 1.x 只能增加:新的接口和新的可选导出,不能修改已有的 record 或 variant。改 `wit/` 之前请先读 `plugins/README.md` 里的 _Evolving the plugin API_。
+1 -1
View File
@@ -3,7 +3,7 @@ title: "API 参考"
description: "openidea:plugin WIT 包中的每个 interface:一句话说明及源码链接。"
---
插件 API 是 [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中的 WIT 包 `openidea:plugin@1.6.0`。WIT 文件就是参考文档:每个 interface、类型和函数都在注释中有说明。1.6.0 是 `main` 上的版本;最新已发布版本是 1.4.0,其 WIT 保存在 [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released)。
插件 API 是 [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中的 WIT 包 `openidea:plugin@1.7.0`。WIT 文件就是参考文档:每个 interface、类型和函数都在注释中有说明。1.7.0 是 `main` 上的版本;最新已发布版本是 1.6.0,其 WIT 保存在 [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released)。
<Info>
自动生成的 API 参考正在计划中。在此之前,请参考下面的 WIT 文件、Rust SDK 的 `cargo
+1 -1
View File
@@ -9,7 +9,7 @@ description: "插件代码如何运行:WebAssembly 组件、WIT world、宿主
| 部分 | 位置 | 作用 |
| -------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| 插件 API | [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) | 契约:WIT 包 `openidea:plugin@1.6.0`、其中的 interface 和 world |
| 插件 API | [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) | 契约:WIT 包 `openidea:plugin@1.7.0`、其中的 interface 和 world |
| Rust SDK | [`sdk/rust/openidea-plugin`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin) | WIT 的类型化封装、`Plugin` trait、`register_plugin!`,以及给 `cargo test` 用的模拟宿主 |
| 插件包 | [`crates/ide-extension`](https://github.com/l0ng-ai/openidea/tree/main/crates/ide-extension) | 清单、能力及其风险等级、授权记录、贡献点、`.oiplugin` 包和插件市场索引 |
| 运行时 | [`crates/ide-plugin`](https://github.com/l0ng-ai/openidea/tree/main/crates/ide-plugin) | wasmtime 引擎、每个插件一个 `Store`、各项限制、每个活跃插件一个 actor 线程 |
+16 -14
View File
@@ -248,11 +248,12 @@ Vite + React 页面由 `plugins/build.sh official/graphviz` 构建到 `web/`,
声明 `engines.openidea = "^1.3"` 后,页面可以通过 `openidea.postMessage` 发送 JSON 消息,请 IDE 打开更多东西。
这些协议只运行通过 `process:<program>` 授权的程序,工作目录是窗口所在的受信任项目根目录,从不经过 shell:
| `op` | 打开什么 |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `pty-spawn`、`pty-input`、`pty-resize`、`pty-kill`、`pty-ack` | 归该 frame 所有的交互式 PTY;输出以 `pty-data`、`pty-exit`、`pty-error` 返回。每个插件最多 8 个会话。 |
| `open-log-tab` | 只读编辑器标签页,持续显示程序输出,可带参数开关。 |
| `open-content-tab` | 内容由插件提供的编辑器标签页,带工具栏操作,用 `content-tab-reply` 回复。 |
| `op` | 打开什么 |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `pty-spawn`、`pty-input`、`pty-resize`、`pty-kill`、`pty-ack` | 归该 frame 所有的交互式 PTY;输出以 `pty-data`、`pty-exit`、`pty-error` 返回。每个插件最多 8 个会话。 |
| `open-log-tab` | 只读编辑器标签页,持续显示程序输出,可带参数开关。 |
| `open-content-tab` | 内容由插件提供的编辑器标签页,带工具栏操作,用 `content-tab-reply` 回复。 |
| `open-diff` | API 1.7:在差异查看器中对比两段文本(`left`、`right`,可选标签、`title`,以及用于高亮的 `fileName`)。 |
消息字段和限制见 [插件指南](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md#web-frame-ptys-api-13);
`crates/ide-plugin/tests/fixtures/` 下已构建的 `web-pty`、`web-log`、`web-content` 测试插件演示了这些协议。
@@ -303,15 +304,16 @@ impl Details for MyPlugin {
以下函数需在窗口的命令或事件中调用:
| 函数 | 打开什么 |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `host::open_location(path, line, column)` | 在编辑器中打开项目文件并定位光标(`project.read`;插件私有目录中的文件也可以)。 |
| `host::open_editor(path, selection)` | API 1.4:打开项目文件并选中一个范围。 |
| `host::open_terminal(title, program, args)` | 在项目根目录运行已批准 `process:<program>` 的终端标签页。 |
| `host::open_terminal_session(key, title, &cmd)` | API 1.4:带 key 的终端;再次使用同一 key 会聚焦它。用 `host::write_terminal(key, text)` 写入;退出时收到 `terminal-exited` 事件。 |
| `host::open_log_tab(key, title, &log)` | API 1.3:基于 `ProcessLog` 的只读日志标签页。每个窗口最多 32 个;暂不支持远程项目。 |
| `host::open_content_tab(&ContentTab { .. })` | API 1.4:不依赖网页视图的内容编辑器标签页;操作以 `content-tab-action` 事件送达,用 `host::reply_content_tab` 回复。 |
| `host::output_append(channel, text)` | API 1.4:每个通道一个只读 **输出** 标签页(32 个通道,各 1 MiB);另有 `output_clear`、`output_reveal`。 |
| 函数 | 打开什么 |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `host::open_location(path, line, column)` | 在编辑器中打开项目文件并定位光标(`project.read`;插件私有目录中的文件也可以)。 |
| `host::open_editor(path, selection)` | API 1.4:打开项目文件并选中一个范围。 |
| `host::open_terminal(title, program, args)` | 在项目根目录运行已批准 `process:<program>` 的终端标签页。 |
| `host::open_terminal_session(key, title, &cmd)` | API 1.4:带 key 的终端;再次使用同一 key 会聚焦它。用 `host::write_terminal(key, text)` 写入;退出时收到 `terminal-exited` 事件。 |
| `host::open_log_tab(key, title, &log)` | API 1.3:基于 `ProcessLog` 的只读日志标签页。每个窗口最多 32 个;暂不支持远程项目。 |
| `host::open_content_tab(&ContentTab { .. })` | API 1.4:不依赖网页视图的内容编辑器标签页;操作以 `content-tab-action` 事件送达,用 `host::reply_content_tab` 回复。 |
| `host::open_diff(&ContentDiff { .. }, file_name)` | API 1.7:在差异查看器中对比两段文本,例如评审中某个文件的修改前后;`file_name` 决定语法高亮。 |
| `host::output_append(channel, text)` | API 1.4:每个通道一个只读 **输出** 标签页(32 个通道,各 1 MiB);另有 `output_clear`、`output_reveal`。 |
```rust
use openidea_plugin::{ContentAction, ContentTab, host};
+2 -2
View File
@@ -36,8 +36,8 @@ OpenIDEA 插件可以为 IDE 增加命令、编辑器功能、工具窗口、主
| | |
| -------------- | ----------------------------------------------- |
| 最新已发布 API | **1.4.0**,随 OpenIDEA 0.1.40 发布 |
| 开发中 | `main` 分支上的 **1.6.0**,计划随下一个版本发布 |
| 最新已发布 API | **1.6.0**,随 OpenIDEA 0.1.43 发布 |
| 开发中 | `main` 分支上的 **1.7.0**,计划随下一个版本发布 |
| WIT 包名 | `openidea:plugin@<version>` |
在清单里用 `engines.openidea` 声明插件需要的 API,例如 `engines.openidea = "^1.4"`。
+11 -10
View File
@@ -3,21 +3,22 @@ title: "API 版本与兼容"
description: "engines.openidea、插件 API 从 1.0 到 1.6 的各个版本,以及让已发布插件在新 IDE 中继续加载的规则。"
---
插件 API 只有一个版本号:[`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中 WIT 包的版本(`main` 上是 `openidea:plugin@1.6.0`)。宿主的引擎版本和 SDK 的 `API_VERSION` 始终与它一致,SDK 里也带着一份完全相同的 WIT 文件。
插件 API 只有一个版本号:[`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中 WIT 包的版本(`main` 上是 `openidea:plugin@1.7.0`)。宿主的引擎版本和 SDK 的 `API_VERSION` 始终与它一致,SDK 里也带着一份完全相同的 WIT 文件。
<Note>插件系统目前是预览版。下面的兼容规则适用于已发布的 1.x 版本;`main` 上尚未发布的内容在发布前仍可能变化。</Note>
## 版本历史
| API | 随哪个版本发布 | 主要内容 |
| ----- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | OpenIDEA 0.1.36 | 基础 API:命令、事件、存储、任务、项目和编辑器访问、VCS、诊断、网络与密钥、进程、声明式视图和自定义编辑器、设置、编辑器 provider、syntax provider |
| 1.1.0 | OpenIDEA 0.1.37 | `vcs.remotes`、`ui.copy-to-clipboard`、`ui.open-url`,从视图中打开项目文件的指定行 |
| 1.2.0 | OpenIDEA 0.1.38 | 后台文档索引器;选择器、输入框和确认框;插件自定义的 `when` 上下文键;项目视图中选中的文件;清单 `icon`;`viewItem.context` 菜单 |
| 1.3.0 | OpenIDEA 0.1.39 | 提交信息和提交前检查、Services 详情面板、输入时诊断、悬停、内嵌提示、语义 token、改动文件的代码操作、HTTP 端点、项目外文件(`fs.read`/`fs.write`、`project.write`、插件私有目录)、批量 `files-changed` 事件和路径过滤、工作区符号、长时间运行的进程、带按钮的通知、翻译、多页设置、文件模板变量、Web 视图 PTY、日志标签页和内容标签页 |
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`、`ipc:`、`ide.commands` 和 `database.sources` 能力;编辑器与项目快照、装饰、文本搜索、问题视图、Git log/blame/diff;输出通道;OAuth 回环;运行、终端会话、运行配置、测试结果解析和运行目标;格式化等语言功能;嵌入语言;代码片段;更多菜单位置;数据库数据源;MCP 工具 |
| 1.5.0 | 计划随下一个版本发布 | `Project::notify`(只在某个项目的窗口中显示通知)、`net:ask` 能力、运行目标启动前校验、运行目标按命令设置工作目录参数 |
| 1.6.0 | `main` 上开发中 | 面向事件流插件、受容量限制且绑定项目的后台 WebSocket |
| API | 随哪个版本发布 | 主要内容 |
| ----- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | OpenIDEA 0.1.36 | 基础 API:命令、事件、存储、任务、项目和编辑器访问、VCS、诊断、网络与密钥、进程、声明式视图和自定义编辑器、设置、编辑器 provider、syntax provider |
| 1.1.0 | OpenIDEA 0.1.37 | `vcs.remotes`、`ui.copy-to-clipboard`、`ui.open-url`,从视图中打开项目文件的指定行 |
| 1.2.0 | OpenIDEA 0.1.38 | 后台文档索引器;选择器、输入框和确认框;插件自定义的 `when` 上下文键;项目视图中选中的文件;清单 `icon`;`viewItem.context` 菜单 |
| 1.3.0 | OpenIDEA 0.1.39 | 提交信息和提交前检查、Services 详情面板、输入时诊断、悬停、内嵌提示、语义 token、改动文件的代码操作、HTTP 端点、项目外文件(`fs.read`/`fs.write`、`project.write`、插件私有目录)、批量 `files-changed` 事件和路径过滤、工作区符号、长时间运行的进程、带按钮的通知、翻译、多页设置、文件模板变量、Web 视图 PTY、日志标签页和内容标签页 |
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`、`ipc:`、`ide.commands` 和 `database.sources` 能力;编辑器与项目快照、装饰、文本搜索、问题视图、Git log/blame/diff;输出通道;OAuth 回环;运行、终端会话、运行配置、测试结果解析和运行目标;格式化等语言功能;嵌入语言;代码片段;更多菜单位置;数据库数据源;MCP 工具 |
| 1.5.0 | OpenIDEA 0.1.41 | `Project::notify`(只在某个项目的窗口中显示通知)、`net:ask` 能力、运行目标启动前校验、运行目标按命令设置工作目录参数 |
| 1.6.0 | OpenIDEA 0.1.43 | 面向事件流插件、受容量限制且绑定项目的后台 WebSocket |
| 1.7.0 | `main` 上开发中 | `views.open-diff`:在差异查看器中对比两段文本,例如评审中某个文件的修改前后 |
每个版本新增内容的完整列表见 [`plugins/README.md`](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md)。
+4 -2
View File
@@ -9,7 +9,8 @@ const ROOT = "/dev/acme-api";
async function openBranches(page: Page) {
await page.evaluate(async () => {
const { executeAction } = await import(/* @vite-ignore */ "/src/platform/actions.ts");
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("vcs.branches");
});
const popup = page.getByRole("dialog", { name: "Git Branches" });
@@ -48,7 +49,8 @@ test.beforeEach(async ({ workbench: page }) => {
branch("main", true),
...Array.from({ length: 40 }, (_, i) => branch(`task-${i}-a-long-branch-name-that-gets-cut-off`)),
];
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ "/src/features/vcs/store.ts");
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
await loadBranches();
+23
View File
@@ -74,3 +74,26 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
});
test("commit message history opens above the message without an empty icon column", async ({ workbench: page }) => {
await stubRepository(page);
await page.keyboard.press("Meta+k");
const message = page.getByRole("textbox", { name: "Commit message" });
await expect(message).toBeVisible();
await page.evaluate((root) => {
localStorage.setItem(`openidea.vcs.commitMessages:${root}`, JSON.stringify(["Previous commit message"]));
}, ROOT);
await message.fill("Draft");
await page.getByRole("button", { name: "Commit Message History" }).click();
const menu = page.getByRole("menu", { name: "Recent commit messages" });
const row = menu.getByRole("menuitem", { name: "Previous commit message" });
await expect(row).toBeVisible();
const messageBox = await message.boundingBox();
const menuBox = await menu.locator("..").boundingBox();
expect(messageBox).not.toBeNull();
expect(menuBox).not.toBeNull();
expect(menuBox!.y + menuBox!.height).toBeLessThan(messageBox!.y);
expect(Math.abs(menuBox!.x - messageBox!.x)).toBeLessThan(2);
await expect(row.locator("span")).toHaveCount(1);
});
+15 -3
View File
@@ -36,7 +36,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.6.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.7.0`, world
`plugin`).
## 1. Quick start
@@ -418,9 +418,20 @@ are always available. Calling a gated function without its capability returns
## 3. Writing code (Rust SDK)
### API 1.7
API 1.7.0 is in development on `main`. Use `engines.openidea = "^1.7"` and the
matching SDK. Released 1.6 plugins keep working. New in 1.7:
- `host::open_diff(&ContentDiff { .. }, file_name)` (`views.open-diff`) opens
two texts in the invoking window's diff viewer, for example a merge request's
file before and after; `file_name` picks the syntax highlighting. Web views
send `{ op: "open-diff", left, right, leftLabel?, rightLabel?, title?, fileName? }`.
Commands and events only; each side at most 1 MiB.
### API 1.5
API 1.5.0 is planned for the next OpenIDEA release. Use
API 1.5.0 shipped with OpenIDEA 0.1.41. Use
`engines.openidea = "^1.5"` and the matching SDK. Released 1.4 plugins keep
working. New in 1.5:
@@ -638,6 +649,7 @@ then run `openidea-plugin check`.
| `host::write_terminal(key, text)` | `views.write-terminal` | API 1.4: write only to this plugin's still-running terminal in this window; trusted project, commands/events only. |
| `host::open_content_tab(tab)` | `views.open-content-tab` | API 1.4: the shared content editor protocol, without a web view; actions arrive as `content-tab-action`. |
| `host::reply_content_tab(request_id, reply)` | `views.reply-content-tab` | API 1.4: reply to an owned action in the originating window. No capability; commands/events only. |
| `host::open_diff(diff, file_name)` | `views.open-diff` | API 1.7: two texts in the invoking window's diff viewer; `file_name` (at most 256 characters) picks the highlighting. Commands/events only; each side at most 1 MiB. |
| `RunConfigurations::configurations(root)` | export `run-configurations.configurations` | API 1.4: optional `plugin-run-configurations` world; SDK registration `run_configurations`. Full project list, refreshed on configuration listing. |
| `TestResults::parse(config, chunk)` | export `test-results.parse` | API 1.4: optional `plugin-test-results` world; SDK registration `test_results`. Bounded output batches for `framework = "plugin"`; failures use built-in parsers. |
| `TestResults::select_tests(config, tests)` | export `test-results.select-tests` | API 1.4: full replacement argv for test-tree name paths, or `None` when unsupported (the SDK default). Enables rerunning failed tests and running tree selections. |
@@ -2417,7 +2429,7 @@ the API itself follows _Evolving the plugin API_ below.
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0
with 0.1.37, 1.2.0 with 0.1.38, 1.3.0 with 0.1.39 and 1.4.0 with 0.1.40.
API 1.5.0 is planned with the next release; API 1.6.0 is in development on `main`. wasmtime decides what stays compatible: it resolves interface names
API 1.5.0 shipped with 0.1.41 and 1.6.0 with 0.1.43; API 1.7.0 is in development on `main`. wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more