Compare commits

...
Author SHA1 Message Date
l0ng-ai b396ae6ab2 fix(run): import JetBrains Spring Boot and Application configurations
The JetBrains importer skipped SpringBootApplicationConfigurationType and Application as unsupported types. Both now map to the spring-boot / application forms' command line with their module, working directory and environment; options without a field are listed as left out.
2026-11-05 16:10:03 +08:00
l0ng-ai 26e3ecc3a0 fix(project): open a window's project once when opens race (#1503)
Two opens of one window could each find no current project and build a full project (index, language servers, watchers); the second replaced the first without closing it, leaving two jdtls on one workspace. open_project and open_light now hold a per-window lock from the check to the insert.
2026-11-05 16:05:54 +08:00
Lee Dong Geon 456b329d73 feat(themes): preview themes from the gear menu and Settings, and import theme plugins from every JetBrains IDE (#1498)
The main toolbar gear's Theme submenu and Settings › Appearance › Theme preview the theme under the pointer after the same delay as Quick Switch Scheme, and closing without a choice brings the selected theme back. Choosing a theme while it was previewed left the preview flag on, so the start-up theme cache kept the previous theme; `setTheme` now ends the preview.

Import Theme from JetBrains IDE… lists one row per theme plugin and installs it without switching the theme, like any theme plugin install. It reads only the newest settings folder of each product, so folders left behind by upgrades don't list their plugins again.

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 15:57:04 +08:00
l0ng-ai ecda2223eb chore: v0.1.37 2026-11-05 15:42:10 +08:00
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 15:37:19 +08:00
l0ng-ai 9bf28dd451 chore(terminal): log each session's start, first output and first input (#1493)
A terminal that starts but never shows a prompt left nothing in the log: the
spawn was logged at debug level only. The spawn (shell, pid, size), the first
output (after how long, and whether it is ConPTY's cursor position query) and
the first input (and whether it is the cursor position report) are now logged
at info level, once per session, so a user's log tells whether the shell never
wrote, or its query was never answered.

Refs #1224
2026-11-05 15:23:00 +08:00
l0ng-ai 3025b274f5 fix(github): make Change Repository or Account… work before a repository is connected (#1492)
The action was only enabled while the Pull Requests tool window was connected to
a repository, so from Find Action or a shortcut it did nothing in a project that
wasn't connected yet (gh not installed or not logged in, or several GitHub
remotes to choose from). It is now available in any open project: connected, it
forgets the choice and shows the repository selector as before; not connected,
it opens the Pull Requests tool window, which shows the selector or how to log in.

Fixes #1464
2026-11-05 15:20:47 +08:00
Jaw bdf94926ec fix(vcs): cap the Push dialog's height so a long commit list scrolls inside it (#1491)
With many outgoing commits the Push dialog grew to the full screen height and its
whole body scrolled besides the changed-files tree, because the body was not a flex
container and the commit list's flex had no effect. The single- and multi-repository
Push dialogs now open at most 720px tall with the commit list filling the rest and
scrolling on its own; Dialog gets a maxHeight that applies until the user resizes.

Closes #1485

Co-authored-by: jaw52 <[email protected]>
2026-11-05 15:18:42 +08:00
l0ng-ai 893a549b23 test(plugins): use a function no host has in the missing-import check case
With API 1.1 the host provides ui.copy-to-clipboard, so that case only failed
on its signature. Use a name that never exists so it keeps covering a missing
function.
2026-11-05 15:04:23 +08:00
l0ng-ai 3ef247c55f test(plugins): freeze plugin API 1.0.0
v0.1.36 shipped API 1.0.0, so its WIT is now the snapshot api_frozen checks
every later version against. World items are compared without their @version,
since a later minor renames them and wasmtime resolves the release's names to it.
2026-11-05 15:04:23 +08:00
l0ng-ai 10a3ac92cf fix(plugins): describe the clipboard capability's command use
Since API 1.1 a plugin with `clipboard` can also copy from its commands
(ui.copy-to-clipboard), not only use the clipboard from its web views.
2026-11-05 15:04:23 +08:00
l0ng-ai 43c85ce8f3 fix(gradle): unlink a build by the path the UI sends
Linked builds are saved normalised (canonical, forward slashes), but unlinking
compared the paths it was given verbatim, so a build outside the project could
not be unlinked on Windows (C:\… vs C:/…), nor through a symlinked path. Unlink
now normalises each path the way linking saves it. The Gradle linkage tests also
create the platform's wrapper (gradlew.bat on Windows) and compare canonical
paths without the \\?\ prefix.
2026-11-05 15:04:23 +08:00
l0ng-ai ac436160b5 fix(editor): keep YAML highlighting after comment lines in longer files (#1489)
In some YAML files over about a kilobyte, a mapping that follows comment lines
lost its highlighting from the second comment to the end of the mapping. The
YAML parser (@lezer/yaml with @lezer/lr) can give that mapping node an inverted
range when it falls on a tree buffer boundary (1024 entries by default), and
highlighting skips the whole node. The YAML mode now parses into buffers of
65536 entries, so ordinary YAML files stay in one buffer and never hit the
boundary.

Refs #809
2026-11-05 15:04:02 +08:00
l0ng-ai a3fe841e37 fix(ui): merge adjacent selected tree rows into one block (#1486)
Each selected row kept its own rounded corners, so a contiguous
multi-selection showed notches between rows. Rows whose neighbour is
also selected now drop the radius on that side.
2026-11-05 14:58:12 +08:00
Jaw ed4a8b50c1 feat(vcs): Delete key deletes unversioned files in the Changes view (#1483)
The Changes view's Delete… for unversioned files had no key. When every selected
file is unversioned, Delete/Backspace now opens the same confirmation (files go to
the Trash) and the menu item shows the platform's delete key; Delete on a changelist
group still deletes the changelist.

Closes #1477

Co-authored-by: jaw52 <[email protected]>
2026-11-05 14:50:20 +08:00
DomKingandDomKing e7952ce3d8 fix(vcs): show the diff of the file selected in the changes tree (#1480)
Show Diff (Cmd+D) in the Commit window or the Git window's Local Changes opened the
file still selected in the Project view, which keeps its selection while another view
has focus, instead of the change selected in the tree. Show Diff and Compare with
Branch or Revision now take the action's paths first, then the changes tree's
selection while it has focus, then the Project view's selection, then the active file.

Co-authored-by: DomKing <[email protected]>
2026-11-05 14:48:59 +08:00
l0ng-ai a0595f5c12 fix(deployment): open Sync with Deployed to the default server in its own window (#1482)
Sync with Deployed to… opened the comparison in its own native window on desktop,
but the default-server item, which the context menu shows by the server's name
(and as the only entry when one server is configured), still called the in-page
dialog, so the comparison was confined to the project window. Both actions now
share the same opener: a native window on desktop with a notification when it
can't open, the in-page dialog in the browser build.

Fixes #1459
2026-11-05 14:38:24 +08:00
payailk 090a1cd6df feat(android): add a Logcat tool window (#1473)
A bottom tool window lists the connected Android devices (adb devices -l) and
streams the selected one's log (adb logcat -v threadtime, the last 200 lines first),
parsed into time, pid, level, tag and message in a virtualised list that follows the
end until scrolled up. The filter box takes Android Studio's logcat expressions
(tag:, message:, pid:, level:, -term, |, parentheses, regex) and remembers recent
ones. The new ide-android crate runs adb, which it finds through ANDROID_HOME /
ANDROID_SDK_ROOT, the usual SDK locations or PATH; the stream goes over a Channel
in batches (300 lines, or once the oldest waiting line is 60 ms old) and stops with
its request. The window is listed under the stripe's overflow until first opened.

Co-authored-by: payailk <[email protected]>
2026-11-05 14:21:50 +08:00
Deng Liu 2b86dd2d9f feat(agents): combine model and effort, and mode and Allow All, in the composer (#958)
- One composer button shows the model and its effort; its popup has the searchable model list and a stepped effort slider over the levels the agent advertises.
- With an on/off permissions option (Allow All), the mode menu carries it as a switch, and new tasks start with it on like the other bypass settings.
- Recognise ACP session-mode URIs when picking mode icons.
- The Stop action in a running session is an icon button.
2026-11-05 14:20:26 +08:00
payailk 566190e94b feat(debugger): debug Flutter apps through the Flutter debug adapter (#1474)
Dart projects that depend on the Flutter SDK were debugged through
`dart debug_adapter`, which cannot start a program importing dart:ui and has no
hot reload. A session now uses `flutter debug_adapter` when the program's or the
working directory's pubspec.yaml depends on the Flutter SDK, or when an attach
configuration asks for it (add-to-app: vm-service-uri and device map to the
adapter's attach arguments). Hot Reload and Hot Restart are sent over DAP and
appear in the toolbar of Dart sessions. Dart files also get a debug configuration.

Co-authored-by: payailk <[email protected]>
2026-11-05 14:14:47 +08:00
payailk 8869f1ce1c fix(debugger): let session tabs be closed (#1472)
Debug session tabs had no close button, and ending a session only marked it
terminated, so every session stayed in the strip and Cmd+W fell through to hiding
the window. Each tab can now be closed: a running session asks before it is
terminated (the Run tab's question, shared through the workbench), then the
session, its children and its console are dropped and the tab on the left is
selected.

Co-authored-by: payailk <[email protected]>
2026-11-05 14:08:37 +08:00
l0ng-ai 04fe65328e fix(plugins): refuse components that don't link against the host in check and install (#1471)
check_component only compared import names and the API version, so a component
built from unreleased WIT (a function added under @1.0.0) or missing the plugin
world's exports passed `openidea-plugin check` and installing, then failed when
the IDE started it. It now links the component against the host's linker and
reports the missing or mismatched function with what to do.

Closes #1468
2026-11-05 14:07:16 +08:00
l0ng-ai 0d80e2c05a feat(vcs): annotate either side of a diff with Git Blame (#1476)
Right-clicking a line or the line numbers of a diff pane now offers Annotate with
Git Blame / Close Annotations. Each side is blamed on its own history: a revision on
that revision (at the path the file had there), the index on HEAD with its own text,
and the working file like the editor's annotations, refreshed after edits and when
HEAD moves. Compare Files, Local History and plain-text sides don't offer it. The
divider between the panes, which had no menu, acts on the pane focused last.

Fixes #1443
2026-11-05 14:03:46 +08:00
l0ng-ai ee5d912157 fix(java): run Application and Spring Boot configs from the project root (#1475)
Configurations created for a main class, and the Spring Boot applications
detected in a module, set their working directory to the module directory.
A relative path in the VM options (such as --module-path=app/server/target/dependency)
then resolved against the module and the JVM failed to start. They now leave the
working directory empty, so the process starts in the project root, which is what
an empty working directory means elsewhere. The module is still stored separately
and keeps supplying the classpath and the debugger's sources.

Fixes #1197
2026-11-05 13:55:34 +08:00
Deng Liuanddengliu 4557811ef8 feat(plugins): add vcs.remotes, ui.copy-to-clipboard and ui.open-url (#1366)
Plugins get three host calls in API 1.1: vcs.remotes (under vcs.read) lists the
repository's remotes, ui.copy-to-clipboard and ui.open-url act on the window that
invoked the plugin's command.

Remote URLs are returned without the user:password part they may carry, and the
two UI calls fail when there is no invoking window instead of reaching every window.

Co-authored-by: dengliu <[email protected]>
2026-11-05 13:49:59 +08:00
l0ng-ai 47f7e07a20 feat(maven): list the profiles of settings.xml in the Maven tool window (#1470)
The Profiles node listed only the POMs' profiles; it now also lists those of the user settings.xml (the configured file, else ~/.m2/settings.xml), checked by default when <activeProfiles> names them or they are activeByDefault, and deactivated with -P!id when unchecked.

Fixes #1442
2026-11-05 13:41:41 +08:00
214 changed files with 6561 additions and 612 deletions

No files matched your search

Generated
+81 -39
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -4071,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4106,9 +4106,20 @@ dependencies = [
"tree-sitter",
]
[[package]]
name = "ide-android"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
"specta",
"tempfile",
"which",
]
[[package]]
name = "ide-build"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4123,7 +4134,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4132,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4149,7 +4160,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4163,7 +4174,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4181,7 +4192,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4207,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4220,7 +4231,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4240,7 +4251,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4252,7 +4263,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4265,7 +4276,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4326,7 +4337,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4344,7 +4355,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4366,7 +4377,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"encoding_rs",
"futures",
@@ -4383,7 +4394,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4439,7 +4450,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dunce",
"futures",
@@ -4465,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
@@ -4488,7 +4499,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"blake3",
"ide-core",
@@ -4504,11 +4515,12 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
"wat",
]
[[package]]
name = "ide-profiler"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4521,7 +4533,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"globset",
"ide-core",
@@ -4538,7 +4550,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4561,7 +4573,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dunce",
"ide-core",
@@ -4580,7 +4592,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"criterion",
"globset",
@@ -4600,7 +4612,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4613,7 +4625,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4635,7 +4647,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-language",
@@ -4650,7 +4662,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"libc",
@@ -4663,7 +4675,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"criterion",
"ide-core",
@@ -4676,7 +4688,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"flate2",
"hex",
@@ -4697,7 +4709,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -4718,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"chardetng",
"dunce",
@@ -4740,7 +4752,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"ide-core",
"open",
@@ -4756,7 +4768,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -6297,7 +6309,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.36"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6307,6 +6319,7 @@ dependencies = [
"gtk",
"ide-agent",
"ide-analysis",
"ide-android",
"ide-build",
"ide-clipboard",
"ide-core",
@@ -6361,6 +6374,7 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"tempfile",
"tokio",
"tracing",
"tracing-appender",
@@ -8362,7 +8376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -10305,6 +10319,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "universal-hash"
version = "0.6.1"
@@ -10825,6 +10845,28 @@ dependencies = [
"wasmtime",
]
[[package]]
name = "wast"
version = "258.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.258.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.36"
version = "0.1.37"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ide-android"
description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
specta.workspace = true
which = "8"
[dev-dependencies]
tempfile.workspace = true
+222
View File
@@ -0,0 +1,222 @@
//! Android devices and their logs, driven through the `adb` CLI (like git is
//! driven through its CLI).
//!
//! The Logcat tool window needs two things from a device: that it is there, and
//! what it prints. Both come from `adb`, which the Android SDK installs — the
//! IDE does not bundle it.
//!
//! Every function blocks; callers run them off the UI / command threads.
mod model;
mod process;
pub use model::*;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
/// `adb devices` is quick unless it starts the daemon, which can take a moment.
const DEVICES: Duration = Duration::from_secs(20);
/// `adb logcat -c` only truncates the buffers.
const CLEAR: Duration = Duration::from_secs(15);
/// How much of the buffer to show before following, so opening the window is not empty.
const TAIL_LINES: u32 = 200;
/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
const BATCH_IDLE: Duration = Duration::from_millis(60);
const BATCH_MAX: usize = 300;
fn exe(name: &str) -> String {
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
}
/// Android SDK locations, most specific first: the environment, then the usual
/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
fn sdk_roots() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
.iter()
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
.collect();
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
if let Some(home) = home.filter(|_| !cfg!(windows)) {
roots.push(home.join("Library/Android/sdk"));
roots.push(home.join("Android/Sdk"));
}
if cfg!(windows)
&& let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
{
roots.push(local.join("Android/Sdk"));
}
roots
}
/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
pub fn adb_executable() -> Option<PathBuf> {
static ADB: OnceLock<Option<PathBuf>> = OnceLock::new();
ADB.get_or_init(|| {
if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(explicit);
}
let name = exe("adb");
if let Some(found) =
sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
{
return Some(found);
}
if let Ok(found) = which::which("adb") {
return Some(found);
}
let mut candidates: Vec<PathBuf> = if cfg!(windows) {
vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
} else {
["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
};
candidates.extend([
PathBuf::from("/opt/android-sdk/platform-tools/adb"),
PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
]);
candidates.into_iter().find(|p: &PathBuf| p.is_file())
})
.clone()
}
fn executable() -> IdeResult<PathBuf> {
adb_executable().ok_or_else(|| {
IdeError::unavailable(
"adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
)
})
}
/// The directory holding adb, for command lines that expect `adb` on `PATH`.
pub fn platform_tools_dir() -> Option<&'static Path> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
}
/// Connected devices, online ones first; starting the adb daemon when needed.
pub fn devices() -> IdeResult<Vec<AndroidDevice>> {
let adb = executable()?;
let mut cmd = Command::new(adb);
cmd.args(["devices", "-l"]);
let out = process::run(cmd, DEVICES, None)?;
if !out.success {
let message = out.stderr.trim();
return Err(IdeError::unavailable(if message.is_empty() {
"adb devices failed".to_owned()
} else {
format!("adb devices failed: {message}")
}));
}
Ok(parse_devices(&out.stdout))
}
/// Empties the device's log buffers (`adb logcat -c`).
pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
let adb = executable()?;
let mut cmd = Command::new(adb);
cmd.args(["-s", serial, "logcat", "-c"]);
let out = process::run(cmd, CLEAR, Some(cancel))?;
if out.success {
Ok(())
} else {
Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
}
}
fn command(adb: &Path, serial: &str, tail: Option<u32>) -> Command {
let mut cmd = Command::new(adb);
cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
if let Some(lines) = tail {
// Recent history first, then follow: `-T <count>` is lines, not an exit.
cmd.arg("-T").arg(lines.to_string());
}
cmd
}
/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
/// With `clear`, the buffers are emptied first so the window starts clean.
pub fn logcat(
serial: &str,
clear: bool,
cancel: &CancellationToken,
on_batch: &mut dyn FnMut(Vec<LogcatLine>),
) -> IdeResult<()> {
let adb = executable()?;
if clear {
clear_log(serial, cancel)?;
}
let mut parse = |line: &str| Some(parse_line(line));
let (ok, stderr) = match process::stream_batched(
command(&adb, serial, Some(TAIL_LINES)),
cancel,
BATCH_IDLE,
BATCH_MAX,
&mut parse,
on_batch,
) {
Ok(result) => result,
// The window closed or switched devices: stopping is the expected end.
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
Err(e) => return Err(e),
};
if ok || cancel.is_cancelled() {
return Ok(());
}
// `-T` is old (API 21) but adb forwards logcat options it does not know on
// very old devices; retry without the history rather than showing nothing.
let unsupported = stderr.iter().any(|line| {
line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
});
if unsupported {
let (ok, stderr) = match process::stream_batched(
command(&adb, serial, None),
cancel,
BATCH_IDLE,
BATCH_MAX,
&mut parse,
on_batch,
) {
Ok(result) => result,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
Err(e) => return Err(e),
};
if ok || cancel.is_cancelled() {
return Ok(());
}
return Err(failure(serial, &stderr));
}
Err(failure(serial, &stderr))
}
fn failure(serial: &str, stderr: &[String]) -> IdeError {
let message = stderr.join("\n");
IdeError::unavailable(if message.trim().is_empty() {
format!("adb logcat on {serial} stopped")
} else {
format!("adb logcat on {serial} stopped: {message}")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adb_candidates_are_absolute() {
for root in sdk_roots() {
assert!(root.is_absolute(), "{root:?}");
}
}
#[test]
fn executable_is_absolute_when_found() {
if let Some(adb) = adb_executable() {
assert!(adb.is_absolute());
assert!(adb.is_file());
}
}
}
+276
View File
@@ -0,0 +1,276 @@
//! What the Logcat tool window shows: the devices `adb` reports and the lines
//! `adb logcat` prints, parsed from its `threadtime` format.
use serde::{Deserialize, Serialize};
use specta::Type;
/// One entry of `adb devices -l`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AndroidDevice {
/// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
pub serial: String,
/// `device` (usable), `offline`, `unauthorized` or `no permissions`.
pub state: String,
pub model: Option<String>,
pub product: Option<String>,
/// The device's own name from the build (`ro.product.device`).
pub device: Option<String>,
pub transport_id: Option<String>,
}
impl AndroidDevice {
/// Ready for `logcat` / `install`.
pub fn is_online(&self) -> bool {
self.state == "device"
}
/// What the picker calls it: the model when known, the serial otherwise.
pub fn label(&self) -> &str {
self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
}
}
/// The single-letter logcat priority, in increasing severity.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum LogcatLevel {
Verbose,
Debug,
Info,
Warn,
Error,
Fatal,
/// A line logcat prints outside the message format (`--------- beginning of main`).
Unknown,
}
impl LogcatLevel {
pub fn letter(self) -> char {
match self {
Self::Verbose => 'V',
Self::Debug => 'D',
Self::Info => 'I',
Self::Warn => 'W',
Self::Error => 'E',
Self::Fatal => 'F',
Self::Unknown => '?',
}
}
fn parse(c: char) -> Self {
match c.to_ascii_uppercase() {
'V' => Self::Verbose,
'D' => Self::Debug,
'I' => Self::Info,
'W' => Self::Warn,
'E' => Self::Error,
'F' | 'A' => Self::Fatal,
_ => Self::Unknown,
}
}
}
/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogcatLine {
/// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
pub time: String,
pub pid: i32,
pub tid: i32,
pub level: LogcatLevel,
pub tag: String,
pub message: String,
}
impl LogcatLine {
/// A line logcat printed outside the message format, kept so nothing is lost.
fn unparsed(text: &str) -> Self {
Self {
time: String::new(),
pid: 0,
tid: 0,
level: LogcatLevel::Unknown,
tag: String::new(),
message: text.to_owned(),
}
}
}
/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
pub fn parse_devices(output: &str) -> Vec<AndroidDevice> {
let mut devices: Vec<AndroidDevice> = Vec::new();
for line in output.lines() {
let line = line.trim_end();
if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
continue;
}
let mut fields = line.split_whitespace();
let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
continue;
};
// "no permissions (user in plugdev group; are your udev rules wrong?)"
// is the one state that carries spaces; the rest is a `key:value` list.
let mut device = AndroidDevice {
serial: serial.to_owned(),
state: state.to_owned(),
model: None,
product: None,
device: None,
transport_id: None,
};
if device.state == "no" {
device.state = "no permissions".to_owned();
devices.push(device);
continue;
}
for field in fields {
let Some((key, value)) = field.split_once(':') else {
continue;
};
match key {
"model" => device.model = Some(value.to_owned()),
"product" => device.product = Some(value.to_owned()),
"device" => device.device = Some(value.to_owned()),
"transport_id" => device.transport_id = Some(value.to_owned()),
_ => {}
}
}
devices.push(device);
}
// Online first, then by name, so the picker's default is the device in hand.
devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
devices
}
/// Parses one `logcat -v threadtime` line:
///
/// ```text
/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
/// ```
///
/// Text that does not follow the format still becomes a line, so a device that
/// prints something unexpected does not look like a dead stream.
pub fn parse_line(text: &str) -> LogcatLine {
let Some(line) = parse_formatted(text) else {
return LogcatLine::unparsed(text);
};
line
}
fn parse_formatted(text: &str) -> Option<LogcatLine> {
// "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
let stamp = text.get(..18)?;
if !looks_like_stamp(stamp) {
return None;
}
let mut rest = text.get(18..)?;
let pid: i32 = take_field(&mut rest)?.parse().ok()?;
let tid: i32 = take_field(&mut rest)?.parse().ok()?;
let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
let tail = rest.trim_start();
// "Tag : message": logcat pads the tag to 8 columns, and the tag
// itself never contains a colon followed by a space.
let (tag, message) = match tail.split_once(": ") {
Some((tag, message)) => (tag.trim_end(), message),
None => (tail.trim_end(), ""),
};
Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
}
/// Consumes the next space-separated field of `rest`, leaving the remainder.
fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
*rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let field = &rest[..end];
*rest = &rest[end..];
Some(field)
}
fn looks_like_stamp(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() == 18
&& bytes[2] == b'-'
&& bytes[5] == b' '
&& bytes[8] == b':'
&& bytes[11] == b':'
&& bytes[14] == b'.'
&& bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_adb_devices_with_attributes() {
let out = "\
List of devices attached
HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
emulator-5554 offline transport_id:2
0123456789ABCDEF unauthorized usb:1-1 transport_id:3
";
let devices = parse_devices(out);
assert_eq!(devices.len(), 3);
// Online first, then by serial.
assert_eq!(devices[0].serial, "HT7A1B2C3D4");
assert_eq!(devices[0].state, "device");
assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
assert!(devices[0].is_online());
assert_eq!(devices[1].serial, "0123456789ABCDEF");
assert_eq!(devices[1].state, "unauthorized");
assert_eq!(devices[1].label(), "0123456789ABCDEF");
assert_eq!(devices[2].serial, "emulator-5554");
assert!(!devices[2].is_online());
}
#[test]
fn reads_adb_devices_when_the_list_is_empty() {
assert!(parse_devices("List of devices attached\n\n").is_empty());
}
#[test]
fn reads_no_permissions() {
let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].state, "no permissions");
}
#[test]
fn parses_a_threadtime_line() {
let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
assert_eq!(line.time, "11-04 12:52:39.123");
assert_eq!(line.pid, 3048);
assert_eq!(line.tid, 3059);
assert_eq!(line.level, LogcatLevel::Info);
assert_eq!(line.tag, "WindowManager");
assert_eq!(line.message, "Displayed activity");
}
#[test]
fn keeps_colons_in_the_message() {
let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
assert_eq!(line.tag, "flutter");
assert_eq!(line.level, LogcatLevel::Error);
assert_eq!(line.message, "java.lang.IllegalStateException: boom");
}
#[test]
fn keeps_lines_outside_the_format() {
let line = parse_line("--------- beginning of main");
assert_eq!(line.level, LogcatLevel::Unknown);
assert_eq!(line.message, "--------- beginning of main");
}
#[test]
fn levels_order_by_severity() {
assert!(LogcatLevel::Warn > LogcatLevel::Info);
assert!(LogcatLevel::Fatal > LogcatLevel::Error);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
//! that are handed over in batches (logcat is far too chatty for one message a line).
use ide_core::process::terminate_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
pub success: bool,
}
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
ide_core::process::prepare(ide_core::process::own_group(cmd));
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}")))
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
ide_core::output_encoding::decode_tool_output(&buf)
})
}
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
prepare(&mut cmd);
let mut child = spawn(&mut cmd)?;
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::unavailable("adb did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(Output {
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
success: status.success(),
})
}
enum Event {
Line(bool, String),
Closed,
}
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stdout: bool) {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
let flush = |pending: &mut Vec<u8>| {
if !pending.is_empty() {
let line = ide_core::output_encoding::decode_tool_output(pending);
pending.clear();
let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
}
};
loop {
match pipe.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
for &b in &buf[..n] {
if b == b'\n' {
flush(&mut pending);
} else if b != b'\r' {
pending.push(b);
}
}
}
}
}
flush(&mut pending);
let _ = tx.send(Event::Closed);
});
}
fn flush<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
if !batch.is_empty() {
on_batch(std::mem::take(batch));
}
}
/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
/// tail for the error message. Returns whether the child exited successfully.
pub(crate) fn stream_batched<T>(
mut cmd: Command,
cancel: &CancellationToken,
idle: Duration,
max: usize,
mut parse: impl FnMut(&str) -> Option<T>,
on_batch: &mut dyn FnMut(Vec<T>),
) -> IdeResult<(bool, Vec<String>)> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
prepare(&mut cmd);
let mut child = spawn(&mut cmd)?;
let (tx, rx) = mpsc::channel::<Event>();
let mut open = 0;
if let Some(out) = child.stdout.take() {
spawn_reader(out, tx.clone(), true);
open += 1;
}
if let Some(err) = child.stderr.take() {
spawn_reader(err, tx.clone(), false);
open += 1;
}
drop(tx);
let mut stderr_tail: Vec<String> = Vec::new();
let mut batch: Vec<T> = Vec::with_capacity(max);
// When the first line of the waiting batch arrived.
let mut since = Instant::now();
while open > 0 {
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
match rx.recv_timeout(wait) {
Ok(Event::Line(true, line)) => {
if let Some(item) = parse(&line) {
if batch.is_empty() {
since = Instant::now();
}
batch.push(item);
if batch.len() >= max || since.elapsed() >= idle {
flush(&mut batch, on_batch);
}
}
}
Ok(Event::Line(false, line)) => {
if !line.trim().is_empty() {
stderr_tail.push(line);
if stderr_tail.len() > 20 {
stderr_tail.remove(0);
}
}
}
Ok(Event::Closed) => open -= 1,
Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
flush(&mut batch, on_batch);
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if cancel.is_cancelled() {
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
};
Ok((status.success(), stderr_tail))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn a_steady_stream_is_handed_over_while_it_runs() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
let mut sizes = Vec::new();
let (ok, _) = stream_batched(
cmd,
&CancellationToken::new(),
Duration::from_millis(60),
300,
|line| Some(line.to_owned()),
&mut |batch| sizes.push(batch.len()),
)
.unwrap();
assert!(ok);
assert_eq!(sizes.iter().sum::<usize>(), 20);
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Drives a real device through `adb`, checking what the Logcat tool window does:
//! that the device list parses, that the stream delivers batches, and that
//! cancelling stops both the stream and its `adb` process.
//!
//! Ignored by default (needs a connected device). Run with
//! `OPENIDEA_ADB_DEVICE=<serial> cargo test -p ide-android --test device -- --ignored --nocapture`
//! — without the variable, the connected device is used when there is exactly one.
use ide_android::*;
use ide_core::CancellationToken;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
fn serial() -> Option<String> {
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
return Some(serial);
}
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
(online.len() == 1).then(|| online[0].serial.clone())
}
#[test]
#[ignore]
fn finds_the_adb_executable() {
let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
println!("adb: {}", adb.display());
assert!(adb.is_file());
}
#[test]
#[ignore]
fn lists_connected_devices() {
let devices = devices().expect("adb devices");
for device in &devices {
println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
}
assert!(!devices.is_empty(), "no devices connected");
}
#[test]
#[ignore]
fn streams_batches_and_stops_on_cancel() {
let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
let cancel = CancellationToken::new();
let stop = cancel.clone();
let received = Arc::new(Mutex::new(Vec::<LogcatLine>::new()));
let sink = received.clone();
let started = Instant::now();
let stream = std::thread::spawn(move || {
ide_android::logcat(&serial, false, &stop, &mut |batch| {
sink.lock().unwrap().extend(batch);
})
});
// Enough to see the tail seed and a few live lines from a device that idles.
std::thread::sleep(Duration::from_secs(4));
cancel.cancel();
stream.join().unwrap().expect("logcat stops cleanly when cancelled");
let lines = received.lock().unwrap();
println!("{} lines in {:?}", lines.len(), started.elapsed());
for line in lines.iter().rev().take(5) {
println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
}
assert!(!lines.is_empty(), "no lines within 4s");
assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
}
+7 -5
View File
@@ -821,13 +821,14 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join(dir).join("gradlew"), "").unwrap();
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
@@ -842,7 +843,7 @@ mod tests {
assert_eq!(all.steps[1].cwd, root.join("b"));
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
assert_eq!(module.steps.len(), 1);
assert_eq!(module.steps[0].program, root.join("b/gradlew").to_string_lossy());
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
assert_eq!(module.steps[0].cwd, root.join("b"));
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
@@ -854,7 +855,7 @@ mod tests {
nested.path = ":b:app".into();
model.gradle[0].projects.push(nested);
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
assert_eq!(nested.steps[0].program, root.join("b/gradlew").to_string_lossy());
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
model.gradle.clear();
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
@@ -862,6 +863,7 @@ mod tests {
#[test]
fn absolute_linked_roots_build_the_project_and_external_source_module() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("workspace");
@@ -871,7 +873,7 @@ mod tests {
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
let source = external.join("app/src/main/java/App.java");
std::fs::write(&source, "class App {}").unwrap();
std::fs::write(external.join("gradlew"), "").unwrap();
std::fs::write(external.join(wrapper), "").unwrap();
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
@@ -880,7 +882,7 @@ mod tests {
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps[0].cwd, external);
assert_eq!(all.steps[0].program, external.join("gradlew").to_string_lossy());
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
assert_eq!(module.steps[0].cwd, external);
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
+33
View File
@@ -481,6 +481,15 @@ pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeR
Ok(out)
}
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
pub fn linked_project_key(root: &Path, dir: &str) -> String {
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
(Ok(root), Ok(path)) => rel(&root, &path),
_ => dir.replace('\\', "/"),
}
}
/// Linked build identities without reading their scripts (also used by index hot paths).
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
match &settings.linked_projects {
@@ -675,6 +684,30 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn a_path_from_the_ui_finds_the_saved_link() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
for dir in [root.join("a"), external.clone()] {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("settings.gradle"), "").unwrap();
}
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
for (dir, sent) in [
("a", native(&root.join("a"))),
("a", "a".to_owned()),
("a", native(&root.join("a").join("..").join("a"))),
("../external", native(&external)),
] {
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
}
// A folder deleted since it was linked still matches its saved entry.
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
}
#[test]
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
let temp = tempfile::tempdir().unwrap();
+7 -1
View File
@@ -80,6 +80,12 @@ pub fn load_linked(
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
// The user settings' profiles can be chosen too (`-P` applies to them as well).
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
if !m.profiles.iter().any(|x| x.id == p.id) {
m.profiles.push(p);
}
}
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
@@ -210,7 +216,7 @@ mod tests {
};
assert_eq!(
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
[external.canonicalize().unwrap()]
[dunce::canonicalize(&external).unwrap()]
);
settings.linked_projects = Some(vec![]);
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
+66
View File
@@ -472,6 +472,41 @@ pub(crate) fn default_local_repo() -> Option<PathBuf> {
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
/// paths allowed), else `~/.m2/settings.xml`.
pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option<PathBuf> {
let configured = settings.user_settings.trim();
if configured.is_empty() {
return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
}
let path = match configured.strip_prefix("~/") {
Some(rest) => dirs::home_dir()?.join(rest),
None => PathBuf::from(configured),
};
Some(if path.is_absolute() { path } else { root.join(path) })
}
/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
let mut profiles: Vec<MavenProfile> = Vec::new();
for profile in root.list("profiles", "profile") {
let Some(id) = profile.text_of("id") else { continue };
if profiles.iter().any(|p| p.id == id) {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
}
profiles
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
@@ -1536,6 +1571,37 @@ mod tests {
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn settings_profiles_are_listed_with_the_active_ones_checked() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("settings.xml");
std::fs::write(
&file,
r#"<settings>
<profiles>
<profile><id>nexus</id></profile>
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
<profile><id>release</id></profile>
<profile><id>nexus</id></profile>
<profile><activation/></profile>
</profiles>
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
let mut s = ide_core::settings::MavenSettings::default();
let home = dirs::home_dir().unwrap();
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
s.user_settings = "conf/settings.xml".into();
assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
s.user_settings = "~/work/settings.xml".into();
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
+1
View File
@@ -24,6 +24,7 @@ pub mod text;
pub mod tool_path;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
pub use error::{IdeError, IdeResult};
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
+137
View File
@@ -0,0 +1,137 @@
//! Projects that live inside a WSL distro are reached from Windows through the
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) share.
use std::path::Path;
/// The WSL launcher on Windows.
pub const EXE: &str = "wsl.exe";
/// A location inside a WSL distro.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WslPath {
pub distro: String,
/// Absolute Linux path, e.g. `/home/me/app`.
pub path: String,
}
/// Splits a WSL share path into distro and Linux path. Pure string work, so it
/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
pub fn parse(path: &Path) -> Option<WslPath> {
let s = path.to_string_lossy().replace('\\', "/");
let s = match s.strip_prefix("//?/UNC/") {
Some(rest) => format!("//{rest}"),
None => s,
};
let rest = s.strip_prefix("//")?;
let (host, rest) = rest.split_once('/')?;
if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
return None;
}
let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
if distro.is_empty() {
return None;
}
Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
}
/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
pub fn is_valid_distro(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& !name.starts_with('-')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
/// and its error messages, UTF-8 for what a command inside the distro prints.
pub fn decode_output(raw: &[u8]) -> String {
if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(raw).into_owned()
}
}
/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
pub fn parse_distro_list(raw: &[u8]) -> Vec<String> {
let text = decode_output(raw);
let mut names: Vec<String> = Vec::new();
for line in text.lines() {
let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
if is_valid_distro(name) && !names.iter().any(|n| n == name) {
names.push(name.to_owned());
}
}
names
}
/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
pub fn list_distros() -> Vec<String> {
#[cfg(windows)]
{
let mut cmd = std::process::Command::new(EXE);
cmd.args(["-l", "-q"]);
crate::process::prepare(&mut cmd);
match cmd.output() {
Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
_ => Vec::new(),
}
}
#[cfg(not(windows))]
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn p(s: &str) -> Option<(String, String)> {
parse(Path::new(s)).map(|w| (w.distro, w.path))
}
#[test]
fn every_spelling_of_the_share_is_recognised() {
let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
}
#[test]
fn distro_root_and_foreign_paths() {
assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
assert_eq!(p(r"\\server\share\a"), None);
assert_eq!(p(r"\\wsl.localhost\"), None);
assert_eq!(p("C:/Users/me"), None);
assert_eq!(p("/home/me"), None);
}
fn utf16(s: &str) -> Vec<u8> {
std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
}
#[test]
fn distro_list_is_decoded_from_utf16_and_utf8() {
let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
assert!(
parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
);
assert!(parse_distro_list(b"").is_empty());
}
#[test]
fn distro_names_cannot_smuggle_options_or_paths() {
for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
assert!(!is_valid_distro(bad), "{bad}");
}
assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
}
}
+43
View File
@@ -302,6 +302,23 @@ pub struct DebugManager {
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
/// `cwd`; the program's own directory is tried first because a module inside a
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
start.push(dir);
start
.iter()
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
.any(|s| s.contains("sdk: flutter"))
}
fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
@@ -570,6 +587,10 @@ impl DebugManager {
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
// one regardless of what the working directory holds.
let force_flutter =
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
let cwd = config
.cwd
.clone()
@@ -601,6 +622,15 @@ impl DebugManager {
spec.command = python;
}
}
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
// is Flutter-only). Plain Dart projects keep the configured `dart`.
if config.adapter == "dart" && spec.command == "dart" {
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
if force_flutter || is_flutter_project(&cwd, program) {
spec.command = "flutter".into();
}
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
@@ -1236,6 +1266,19 @@ impl DebugManager {
Ok(())
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Flutter's DAP adapter: restarts the app without preserving state.
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
let command = match kind {
StepKind::Over => "next",
+16
View File
@@ -1269,3 +1269,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
let app = root.join("app");
let tool = root.join("tool");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::create_dir_all(tool.join("bin")).unwrap();
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
assert!(is_flutter_project(&app, None));
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
assert!(!is_flutter_project(&root, None));
std::fs::remove_dir_all(&root).unwrap();
}
+3 -1
View File
@@ -79,7 +79,9 @@ impl Capability {
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => "Read and write the clipboard from its web views".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
+1 -1
View File
@@ -32,7 +32,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.0.0";
pub const ENGINE_VERSION: &str = "1.1.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.
+10
View File
@@ -86,6 +86,8 @@ pub struct Host {
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
/// One open at a time per window (see [`Host::opening`]).
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
}
/// How long exit waits for project shutdown before giving up.
@@ -135,6 +137,7 @@ impl Host {
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -401,6 +404,13 @@ impl Host {
self.requests.cancel(id);
}
/// The lock an open of `window` holds from its check for the current project to its
/// insert: two opens racing (a double click, a launch path and a restore) would each start
/// an index, language servers and watchers, and the second would replace the first unclosed.
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
}
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
self.projects.write().insert(window.to_owned(), project);
}
+2 -1
View File
@@ -137,7 +137,8 @@ fn linked_gradle_paths(
}
}
} else {
linked.retain(|path| !paths.contains(path));
let unlinked: Vec<String> = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
}
Ok(linked)
}
+8
View File
@@ -162,6 +162,14 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_restart(session).await
}
pub async fn debug_step(
host: &Host,
_window: &str,
+57
View File
@@ -324,6 +324,18 @@ impl HostApi for PluginApi {
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let client = from_window(client, "open a URL")?;
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
Ok(())
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
@@ -418,6 +430,12 @@ impl HostApi for PluginApi {
}))
}
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
let remotes = repo.remotes().map_err(failed)?;
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
@@ -812,3 +830,42 @@ mod tests {
);
}
}
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
/// follow what the user did, never background work (which would reach every window).
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
}
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
fn without_credentials(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
let authority_end = rest.find('/').unwrap_or(rest.len());
match rest[..authority_end].rfind('@') {
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
None => url.to_owned(),
}
}
#[cfg(test)]
mod credential_tests {
use super::*;
#[test]
fn remote_urls_lose_their_credentials() {
for (url, expected) in [
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
("https://github.com/o/r.git", "https://github.com/o/r.git"),
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
("[email protected]:o/r.git", "[email protected]:o/r.git"),
("/srv/git/r.git", "/srv/git/r.git"),
] {
assert_eq!(without_credentials(url), expected, "{url}");
}
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
}
}
+11 -1
View File
@@ -422,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
let mut products = std::collections::HashSet::new();
out.retain(|ide| products.insert(ide.product.clone()));
out
}
@@ -784,6 +787,13 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
)
}
+6
View File
@@ -197,6 +197,12 @@ pub enum PluginEvent {
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
/// Copy `text` to the system clipboard.
#[serde(rename_all = "camelCase")]
CopyToClipboard { plugin_id: String, text: String },
/// Open `url` (`http(s)://`) in the system browser.
#[serde(rename_all = "camelCase")]
OpenUrl { plugin_id: String, url: String },
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
+4
View File
@@ -77,6 +77,8 @@ impl Host {
lap = std::time::Instant::now();
};
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Reject a damaged manifest before replacing the currently open project.
ide_workspace::workspace::Workspace::load(&root)?;
// The window reloaded (or asked again) for the project it already has: keep
@@ -569,6 +571,8 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let opening = self.opening(window);
let _opening = opening.lock();
if let Ok(current) = self.project(window)
&& current.light
{
+2
View File
@@ -251,6 +251,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "wsl_distros"
| "wsl_resolve"
| "remote_list"
| "remote_open"
| "remote_of"
+46
View File
@@ -0,0 +1,46 @@
//! Opening the project a window is already opening reuses it: racing opens (a double
//! click, a launch path and a restore) must not start a second index and language servers.
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::sync::{Arc, Barrier};
#[test]
fn racing_opens_of_one_window_share_one_project() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let start = Arc::new(Barrier::new(4));
let opens: Vec<_> = (0..4)
.map(|_| {
let (host, root, start) = (host.clone(), root.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
host.open_project("main", &root).unwrap()
})
})
.collect();
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
let current = host.project("main").unwrap();
for project in &projects {
assert!(Arc::ptr_eq(project, &current), "every open returns the window's one project");
}
host.close_project("main");
}
+56
View File
@@ -2077,6 +2077,62 @@ fn views_open_project_files_at_a_line() {
assert!(opened.is_none());
}
#[test]
fn vcs_remotes_clipboard_and_open_url() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
// Not a git repository yet: `vcs-remotes` returns an empty list.
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
git(&f.project, &["init", "-b", "main"]);
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
assert_eq!(
f.run("jobs", "remotes", None).unwrap().as_deref(),
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
);
f.host.set_capability("jobs", "vcs.read", false).unwrap();
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
f.take_events();
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
_ => None,
});
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
f.host.set_capability("jobs", "clipboard", false).unwrap();
f.take_events();
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
f.take_events();
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
_ => None,
});
assert_eq!(
opened,
Some(
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
)
);
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
f.take_events();
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
}
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
+4
View File
@@ -49,6 +49,10 @@ pub mod framing {
}
return Ok(None);
}
// A UTF-16LE line break is `\n\0`: the cut after `\n` leaves a NUL at the start of the
// next line, which would turn the `Content-Length` after it into noise.
let nuls = line.iter().take_while(|&&b| b == 0).count();
line.drain(..nuls);
if line.last() != Some(&b'\n') {
if n as u64 >= MAX_HEADER_LINE {
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
+15
View File
@@ -69,6 +69,21 @@ mod framing_tests {
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
}
#[tokio::test]
async fn utf16_output_of_wsl_exe_before_a_message_does_not_swallow_it() {
// `wsl.exe` reports its own errors as UTF-16LE with a BOM; the real server only starts
// after it, so the first message must still be read whole.
let notice = "The Windows Subsystem for Linux instance has terminated.";
let mut input: Vec<u8> = [0xff, 0xfe].into();
input.extend(format!("{notice}\r\n").encode_utf16().flat_map(u16::to_le_bytes));
input.extend(b"Content-Length: 2\r\n\r\n{}");
let (msgs, noise) = read_with_noise(&input).await;
assert_eq!(msgs.unwrap(), vec![b"{}".to_vec()]);
// The text is kept for the error shown to the user, whatever the BOM decodes to.
let ascii: String = noise.concat().chars().filter(char::is_ascii).filter(|c| *c != '\0').collect();
assert_eq!(ascii.trim(), notice);
}
#[tokio::test]
async fn unterminated_output_then_eof_is_reported() {
// `pause` prints its prompt without a newline and the process is gone.
+1
View File
@@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
wat = "1.258"
+28 -8
View File
@@ -32,7 +32,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.0";
pub const API_VERSION: &str = "1.1";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -45,6 +45,14 @@ pub const CANCELLED: &str = "was cancelled";
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let _ = (client, url);
Ok(())
}
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Roots of the open projects.
@@ -66,6 +74,10 @@ pub trait HostApi: Send + Sync {
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let _ = root;
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
@@ -199,8 +211,10 @@ impl PluginEngine {
/// Rejects a component this host can't run, with a message that says what
/// to do: one importing interfaces outside the plugin API and WASI, or one
/// built for a plugin API this host doesn't implement (another major, or a
/// newer minor). Returns the API version the component was built for
/// (`None`: it uses no plugin interface).
/// newer minor), or one that doesn't link: an import the host lacks or
/// declares with another type (same API version, different WIT), or a
/// missing plugin export. Returns the API version the component was built
/// for (`None`: it uses no plugin interface).
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
let ty = component.component_type();
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
@@ -217,10 +231,20 @@ impl PluginEngine {
if let Some(v) = &built_for {
check_api(v, &host_api())?;
}
let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
Ok(built_for)
}
}
/// Explains a component that doesn't link against this host's plugin API.
fn link_error(detail: &str) -> String {
format!(
"the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
)
}
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
@@ -530,11 +554,7 @@ impl Runtime {
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
let api_hint = |e: wasmtime::Error| {
let msg = describe_trap(&e, &setup.limits);
if msg.contains("openidea:plugin/") {
format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
} else {
msg
}
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
};
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
+26
View File
@@ -242,6 +242,24 @@ impl ui::Host for StoreData {
self.host.status_item(&clip(id, 64), None);
Ok(())
}
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Clipboard) {
return Ok(Err(e));
}
let client = self.invocation.client.clone();
Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
}
fn open_url(&mut self, url: String) -> Fallible<()> {
let trimmed = url.trim();
let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
if !valid {
return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
}
let client = self.invocation.client.clone();
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
}
}
impl storage::Host for StoreData {
@@ -438,6 +456,14 @@ impl vcs::Host for StoreData {
let token = self.cancel.token();
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
}
fn remotes(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<(String, String)>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
let root = self.table.get(&project)?.root.clone();
Ok(self.host.vcs_remotes(&root).map_err(to_wit))
}
}
impl events::Host for StoreData {
@@ -0,0 +1,43 @@
//! `PluginEngine::check_component` (what installing and `openidea-plugin check`
//! run) refuses a component the IDE could not start: every import has to link
//! against the host, function by function, and the plugin world's exports have
//! to be there, not only the interface names and their API version.
use ide_plugin::PluginEngine;
fn check(wat: &str) -> Result<Option<semver::Version>, String> {
let engine = PluginEngine::new().unwrap();
let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap();
engine.check_component(&component)
}
#[test]
fn components_the_host_cannot_link_are_refused() {
let cases = [
// As dengliu.gitlink 0.1.0 was: built from unreleased WIT that added functions under the same version.
(
"a function the host's interface lacks",
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
(export "not-a-host-function" (func (param "text" string))))))"#,
"not-a-host-function",
),
(
"a function with another signature",
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
(export "show-notification" (func (param "title" string))))))"#,
"show-notification",
),
(
"an interface the host lacks",
r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance
(export "copy" (func (param "text" string))))))"#,
"openidea:plugin/[email protected]",
),
("no plugin exports", r#"(component (import "openidea:plugin/[email protected]" (instance)))"#, "lifecycle"),
];
for (case, wat, names) in cases {
let err = check(wat).expect_err(case);
assert!(err.contains(names), "{case}: {err}");
assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}");
}
}
Binary file not shown.
@@ -1 +1 @@
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
Binary file not shown.
@@ -1 +1 @@
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
+1 -1
View File
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
optional = ["project.read", "vcs.read", "clipboard"]
[[contributes.views]]
id = "jobs"
+14 -1
View File
@@ -32,9 +32,22 @@ impl Jobs {
}
impl Plugin for Jobs {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"follow" => self.follow().map(|()| None),
"remotes" => {
let project = ctx.project().ok_or("no project is open")?;
let remotes = project.vcs_remotes()?;
Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::<Vec<_>>().join("\n")))
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
}
"open-url" => {
host::open_url(arg.unwrap_or_default())?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
Binary file not shown.
@@ -1 +1 @@
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
+2 -2
View File
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.0.0;
export openidea:plugin/providers@1.0.0;
include openidea:plugin/plugin-syntax@1.1.0;
export openidea:plugin/providers@1.1.0;
}
",
path: "../../../../../wit",
+2 -3
View File
@@ -564,9 +564,8 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
Some(m) => format!("application:{m}:{class}"),
None => format!("application:{class}"),
};
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
.in_dir(module_rel.as_ref().map(PathBuf::from))
.debug("java-debug");
let mut app =
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
app.extras.module = Some(module_rel.clone().unwrap_or_default());
out.push(app);
}
+105 -2
View File
@@ -3,8 +3,9 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot and compound configurations become the matching kinds; other types
//! are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res<T> = Result<T, SkipReason>;
@@ -329,6 +332,8 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -695,6 +700,54 @@ impl Cx<'_> {
Ok(rc)
}
// ------------------------------------------------------------- Java
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let mut args = self.words(c.option("VM_PARAMETERS"))?;
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
args.push(format!("--spring.profiles.active={profiles}"));
}
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
let mut rc =
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
match module_dir(self.root, module) {
Some(dir) => rc.extras.module = Some(dir),
None => ignored.push(format!("module {module}")),
}
}
// Options the forms have no field for.
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
ignored.push("JRE".into());
}
if c.flag("INCLUDE_PROVIDED_SCOPE") {
ignored.push("provided scope".into());
}
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
ignored.push("classpath modifications".into());
}
if spring {
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
if c.flag(option) {
ignored.push(label.into());
}
}
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
ignored.push("override configuration properties".into());
}
}
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
.sum()
}
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
fn module_dir(root: &Path, name: &str) -> Option<String> {
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
if let Some(dir) = find_module(root, &exact) {
return Some(dir);
}
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
find_module(root, &|dir: &Path| folder(dir) == last)
}
/// The shallowest build folder under `root` (itself included) that `names` accepts.
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
let mut level = vec![root.to_path_buf()];
for _ in 0..5 {
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
let rel = found.strip_prefix(root).ok()?;
return Some(rel.to_string_lossy().replace('\\', "/"));
}
let mut next: Vec<PathBuf> = level
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|entries| entries.flatten().map(|e| e.path()))
.filter(|p| p.is_dir())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
})
.collect();
next.sort();
level = next;
}
None
}
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
fn pom_artifact(dir: &Path) -> Option<String> {
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
_ => pom,
};
let start = own.find("<artifactId>")? + "<artifactId>".len();
let end = start + own[start..].find("</artifactId>")?;
Some(own[start..end].trim().to_owned())
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
+4 -4
View File
@@ -215,7 +215,7 @@ fn builds_nested_in_a_folder_of_repositories() {
assert_eq!(app.name, "DromaraApplication");
assert_eq!(app.kind, "spring-boot");
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
assert_eq!(app.cwd, None);
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
@@ -258,7 +258,7 @@ fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
assert_eq!((app.cwd.as_deref(), app.extras.module.as_deref()), (None, Some(module.as_str())), "{prefix}");
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
}
}
@@ -295,9 +295,9 @@ fn nested_builds_of_every_kind_and_depth() {
}
assert!(find(&c, "gradle:test:services/api").is_test);
let api = find(&c, "application:services/api/app:com.acme.ApiKt");
assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app")));
assert_eq!((api.cwd.as_deref(), api.extras.module.as_deref()), (None, Some("services/api/app")));
let billing = find(&c, "application:billing:Billing");
assert_eq!(billing.cwd.as_deref(), Some(Path::new("billing")));
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
let builds: Vec<&str> =
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
+93 -8
View File
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<configuration name="Main" type="JUnit" factoryName="JUnit">
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
<method v="2" />
</configuration>"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
}
#[test]
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
#[test]
fn java_applications_and_spring_boot_keep_their_module() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"example-service/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
);
// IDEA names a Maven module after its artifactId, which needn't be its folder.
write(
&root,
"services/api/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
);
write(
&root,
".idea/workspace.xml",
&workspace(
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
<option name="HIDE_BANNER" value="true" />
<envs>
<env name="TZ" value="UTC" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="Api" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
<module name="api-service" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="17" />
<method v="2" />
</configuration>
<configuration name="Gone" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="Gone" />
<module name="removed-module" />
</configuration>
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
</configuration>"#,
),
);
let r = import_from(&root, &[]);
let boot = get(&r, "ExampleApplication");
assert_eq!(boot.kind, "spring-boot");
assert_eq!(boot.command, "java");
assert_eq!(
boot.args,
s(&[
"-Xmx512m",
"-Dfile.encoding=UTF-8",
"-cp",
"${classpath}",
"com.example.ExampleApplication",
"--spring.profiles.active=dev,local",
"--server.port=8081",
])
);
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
let api = get(&r, "Api");
assert_eq!(api.kind, "application");
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
assert_eq!(ignored(&r, "Api"), ["JRE"]);
// A module that isn't here any more: the form finds it by the main class.
assert_eq!(get(&r, "Gone").extras.module, None);
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
}
+58 -5
View File
@@ -60,6 +60,9 @@ struct Session {
writer: Mutex<Box<dyn Write + Send>>,
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
exited: Arc<AtomicBool>,
started: Instant,
/// The first input was logged (startup diagnostics, see `spawn`).
input_logged: AtomicBool,
}
type Sessions = Arc<Mutex<HashMap<TerminalId, Arc<Session>>>>;
@@ -92,7 +95,8 @@ impl TerminalManager {
let size = pty_size(options.cols, options.rows);
let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args);
let wsl = if cfg!(windows) { wsl_shell(&options) } else { None };
let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args));
let mut env = terminal_env(&options.env);
if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) {
env.insert("MSYSTEM".to_owned(), msystem.to_owned());
@@ -105,7 +109,8 @@ impl TerminalManager {
}
let mut cmd = CommandBuilder::new(&shell);
cmd.args(&args);
cmd.cwd(resolve_cwd(&options.cwd));
// wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd.
cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) });
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
@@ -136,25 +141,34 @@ impl TerminalManager {
writer: Mutex::new(writer),
killer: Mutex::new(child.clone_killer()),
exited: exited.clone(),
started: Instant::now(),
input_logged: AtomicBool::new(false),
});
self.sessions.lock().insert(id, session);
let (tx, rx) = mpsc::channel::<Msg>();
let spawned = spawn_reader(id, reader, tx.clone())
.and_then(|_| spawn_waiter(id, child, tx))
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink));
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
if let Err(e) = spawned {
if let Some(s) = self.sessions.lock().remove(&id) {
let _ = s.killer.lock().kill();
}
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
}
tracing::debug!(%id, shell, pid = ?info.pid, "terminal spawned");
// Info level, with the first output and input below: a session that starts but never shows a
// prompt can then be told apart in a user's log (no output, or no reply to the shell's query).
tracing::info!(%id, shell, pid = ?info.pid, cols = size.cols, rows = size.rows, "terminal spawned");
Ok(info)
}
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
let session = self.session(id)?;
if !session.input_logged.swap(true, Ordering::Relaxed) {
let cursor_report = data.starts_with("\x1b[") && data.ends_with('R');
let ms = session.started.elapsed().as_millis();
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
}
let mut w = session.writer.lock();
w.write_all(data.as_bytes())?;
w.flush()?;
@@ -387,8 +401,19 @@ fn spawn_pump(
exited: Arc<AtomicBool>,
sessions: Sessions,
sink: SharedSink,
started: Instant,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-term-pump-{}", id.0)).spawn(move || {
let first_output = std::cell::Cell::new(true);
let on_output = move |text: String| {
if first_output.replace(false) {
// ConPTY asks for the cursor position (`ESC[6n`) and waits for the answer.
let cursor_query = text.contains("\x1b[6n");
let ms = started.elapsed().as_millis();
tracing::info!(%id, ms, bytes = text.len(), cursor_query, "terminal first output");
}
on_output(text)
};
let mut decoder = Utf8Decoder::new();
let mut pending = String::new();
let mut batch_started: Option<Instant> = None;
@@ -456,6 +481,16 @@ fn pty_size(cols: u16, rows: u16) -> PtySize {
}
}
/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux
/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win.
fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec<String>)> {
if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() {
return None;
}
let w = ide_core::wsl::parse(&options.cwd)?;
Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path]))
}
fn resolve_cwd(cwd: &Path) -> PathBuf {
if cwd.is_dir() {
return cwd.to_path_buf();
@@ -504,7 +539,7 @@ pub fn available_shells() -> Vec<String> {
}
#[cfg(windows)]
{
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] {
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] {
if let Some(p) = find_in_path(exe) {
add(p);
}
@@ -859,6 +894,24 @@ mod tests {
assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery");
}
#[test]
fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() {
let mut o = sh("true");
o.shell = None;
o.args = vec![];
let (distro, linux_path) = ("Debian-12", "/srv/app");
let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]);
for host in ["wsl.localhost", "wsl$"] {
o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into();
assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}");
}
o.shell = Some("pwsh.exe".into());
assert_eq!(wsl_shell(&o), None);
o.shell = None;
o.cwd = PathBuf::from("/tmp").into();
assert_eq!(wsl_shell(&o), None);
}
#[test]
fn shell_resolution() {
let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]);
+1 -1
View File
@@ -124,7 +124,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
if !fs::metadata(dir)?.is_dir() {
return Err(IdeError::invalid(format!("{} is not a directory", dir.display())));
}
let gitignore = rules::GitignoreStack::new(dir);
let gitignore = rules::GitignoreStack::cached(dir);
let base = gitignore.repo_root().unwrap_or(dir).to_path_buf();
let excludes = ExcludeMatcher::new(excludes);
+112 -5
View File
@@ -8,6 +8,7 @@ use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
/// Exclude globs from settings (`files.exclude`), e.g. `**/node_modules`.
///
@@ -72,6 +73,14 @@ pub(crate) fn find_repo_root(start: &Path) -> Option<PathBuf> {
start.ancestors().find(|dir| dir.join(".git").exists()).map(Path::to_path_buf)
}
/// `info/exclude` of the repository at `root` (shared by its worktrees).
fn exclude_path(root: &Path) -> PathBuf {
gix::discover(root)
.ok()
.map(|r| r.common_dir().join("info/exclude"))
.unwrap_or_else(|| root.join(".git/info/exclude"))
}
/// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the
/// path's directory up to the repository root (or the file system root when
/// not in a repository), then `.git/info/exclude` and the global excludes file.
@@ -83,6 +92,29 @@ pub(crate) struct GitignoreStack {
nested: Mutex<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
}
/// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory.
const REPO_LEVEL_TTL: Duration = Duration::from_secs(3);
/// What changes when `.git/info/exclude` is edited.
type Stamp = Option<(SystemTime, u64)>;
struct RepoLevel {
at: Instant,
repo_root: Option<PathBuf>,
/// `<common dir>/info/exclude`, found by a repository discovery.
exclude: Option<PathBuf>,
stamp: Stamp,
rules: Vec<Gitignore>,
}
/// Repo-level rules by the directory they were looked up for.
static REPO_LEVEL: Mutex<Option<HashMap<PathBuf, Arc<RepoLevel>>>> = Mutex::new(None);
fn stamp_of(file: Option<&Path>) -> Stamp {
let meta = std::fs::metadata(file?).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
impl GitignoreStack {
pub fn new(near: &Path) -> Self {
let repo_root = find_repo_root(near);
@@ -90,15 +122,52 @@ impl GitignoreStack {
Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() }
}
/// [`new`](Self::new) for listing a directory over and over (expanding a tree, a slow share):
/// the walk up to the repository root and the repository discovery are reused for
/// [`REPO_LEVEL_TTL`]. An edit of `.git/info/exclude` is still seen at once (its mtime and size
/// are checked), as is any `.gitignore` / `.ignore`, which are read fresh. Only a repository
/// created in the directory, or a change of the global excludes file, can lag by the TTL.
pub fn cached(near: &Path) -> Self {
let now = Instant::now();
let hit = REPO_LEVEL
.lock()
.as_ref()
.and_then(|m| m.get(near))
.cloned()
.filter(|e| now.duration_since(e.at) < REPO_LEVEL_TTL && stamp_of(e.exclude.as_deref()) == e.stamp);
let entry = hit.unwrap_or_else(|| {
let repo_root = find_repo_root(near);
let exclude = repo_root.as_deref().map(exclude_path);
let entry = Arc::new(RepoLevel {
at: now,
stamp: stamp_of(exclude.as_deref()),
rules: Self::load_repo_level_from(repo_root.as_deref(), exclude.as_deref()),
repo_root,
exclude,
});
let mut guard = REPO_LEVEL.lock();
let map = guard.get_or_insert_with(HashMap::new);
map.retain(|_, e| now.duration_since(e.at) < REPO_LEVEL_TTL);
map.insert(near.to_path_buf(), entry.clone());
entry
});
Self {
repo_root: entry.repo_root.clone(),
repo_level: RwLock::new(entry.rules.clone()),
cache: Mutex::default(),
nested: Mutex::default(),
}
}
fn load_repo_level(repo_root: Option<&Path>) -> Vec<Gitignore> {
Self::load_repo_level_from(repo_root, repo_root.map(exclude_path).as_deref())
}
fn load_repo_level_from(repo_root: Option<&Path>, exclude: Option<&Path>) -> Vec<Gitignore> {
let mut repo_level = Vec::new();
if let Some(root) = repo_root {
let mut builder = GitignoreBuilder::new(root);
let exclude = gix::discover(root)
.ok()
.map(|r| r.common_dir().join("info/exclude"))
.unwrap_or_else(|| root.join(".git/info/exclude"));
if exclude.is_file() {
if let Some(exclude) = exclude.filter(|f| f.is_file()) {
builder.add(exclude);
}
if let Ok(gi) = builder.build()
@@ -119,6 +188,9 @@ impl GitignoreStack {
/// Re-reads `.git/info/exclude` and the global excludes file.
pub fn reload_repo_level(&self) {
if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) {
map.retain(|dir, _| !dir.starts_with(root));
}
self.nested.lock().clear();
*self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref());
}
@@ -298,4 +370,39 @@ mod tests {
assert!(anchored.is_excluded(Path::new("build/out.o")));
assert!(!anchored.is_excluded(Path::new("web/build/out.o")));
}
fn repo_with_exclude(rule: &str) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join(".git/info/exclude"), rule).unwrap();
tmp
}
#[test]
fn cached_stack_sees_every_edit_of_the_ignore_files_at_once() {
let tmp = repo_with_exclude("secret.txt\n");
let (src, secret, note) =
(tmp.path().join("src"), tmp.path().join("src/secret.txt"), tmp.path().join("src/note.md"));
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false));
assert!(!GitignoreStack::cached(&src).is_ignored(&note, false));
std::fs::write(src.join(".gitignore"), "*.md\n").unwrap();
assert!(GitignoreStack::cached(&src).is_ignored(&note, false), "directory .gitignore");
std::fs::write(tmp.path().join(".git/info/exclude"), "").unwrap();
assert!(!GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude");
std::fs::write(tmp.path().join(".git/info/exclude"), "note.md\nsecret.txt\n").unwrap();
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude again");
}
#[test]
fn cached_stack_reuses_the_repository_lookup() {
let tmp = repo_with_exclude("");
let src = tmp.path().join("src");
let first = GitignoreStack::cached(&src);
let entry = REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap().clone();
let again = GitignoreStack::cached(&src);
assert!(Arc::ptr_eq(&entry, REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap()));
assert_eq!(first.repo_root(), again.repo_root());
assert_eq!(again.repo_root(), Some(tmp.path()));
}
}
+8 -7
View File
@@ -130,14 +130,15 @@ install it from disk.
Only port themes whose licence allows redistribution, keep their licence file,
and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
### From JetBrains IDEs (automatic)
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
To use a theme you already have in a JetBrains IDE, open **Settings › Plugins**, gear
menu:
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
- **Import Theme from JetBrains IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, one row per plugin of
each IDE, the plugins of the themes the IDEs use first. It reads the settings
folder of each IDE's newest version: `~/Library/Application Support/JetBrains`
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
`~/.local/share/JetBrains` (Linux).
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
@@ -146,8 +147,8 @@ menu:
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
install page, and the theme in use stays as it is: choose one of its themes in
**Settings › Appearance › Theme**. Importing the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
+46
View File
@@ -186,3 +186,49 @@ test("quick theme menu offers all four palettes and applies their kind", async (
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
}
});
test("gear › Theme previews the theme under the pointer and keeps the one chosen", async ({ workbench: page }) => {
const html = page.locator("html");
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
const theme = (label: string) => page.getByRole("menuitemcheckbox", { name: label, exact: true });
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("Solarized Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
// A plugin's theme previews too.
await theme("Harbor Night").hover();
await expect(html).toHaveAttribute("data-plugin-theme", /harbor-night$/);
await expect(html).not.toHaveAttribute("data-color-theme");
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(html).not.toHaveAttribute("data-plugin-theme");
await expect(html).toHaveAttribute("data-theme", "light");
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("One Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("One Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
// Chosen, not previewed: the next start-up opens with it.
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
.toBe("one-dark");
});
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
const html = page.locator("html");
const dialog = await settings(page);
await page.getByRole("button", { name: "Theme", exact: true }).click();
await page.getByRole("option", { name: "Solarized Dark", exact: true }).hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await page.keyboard.press("Escape");
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
// Picked: it shows until Apply or Cancel.
await choose(page, "Theme", "One Dark");
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
});
+3 -1
View File
@@ -14,8 +14,10 @@ args = ["language-server", "--protocol=lsp"]
root-markers = ["pubspec.yaml", "analysis_options.yaml"]
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
# `dart` here; a project that depends on the Flutter SDK is switched to
# `flutter debug_adapter` at session start (see `ide-dap`'s `start_with`).
[debugger]
adapter = "dart"
command = "dart"
args = ["debug_adapter"]
install-hint = "Install the Dart SDK (https://dart.dev/get-dart); use `flutter debug_adapter` for Flutter apps"
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.36",
"version": "0.1.37",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+11 -6
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
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world
`plugin`).
## 1. Quick start
@@ -71,7 +71,8 @@ target through rustup if needed), copies the component to the manifest's
`wasm` path and runs `check`. `check` validates the package exactly as
_Install Plugin from Disk…_ would — manifest, every contribution, the SVG
sanitiser, the component — and that the component imports nothing but the
plugin API and WASI. It prints what the user will be asked to grant.
plugin API and WASI and links against the IDE's: every function it imports
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
`link` adds the folder to `links.json` in the IDE's plugins directory
(`$OPENIDEA_PLUGINS_DIR`, default `<app-data>/ai.l0ng.openidea/plugins`). Linked
@@ -183,7 +184,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes) |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
@@ -191,7 +192,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
@@ -299,12 +300,14 @@ then run `openidea-plugin check`.
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. |
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
@@ -766,7 +769,7 @@ granted, and starts the approved program.
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
@@ -942,6 +945,8 @@ only grows:
its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`).
Installing and `openidea-plugin check` refuse another major or a newer
minor with a message that says to update the IDE or rebuild with the SDK.
They also link the component, so one that imports a function this host
lacks under the same version (built from unreleased WIT) is refused too.
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
6. **1.x calls are synchronous and pass documents whole.** Host calls block
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722
-5
View File
@@ -220,11 +220,6 @@ describe("IntelliJ theme import", () => {
],
});
expect(pkg.id).toBe("intellij.com-example-deepsea");
expect(pkg.themes).toEqual([
{ id: "deep-sea", sourceId: "deep-sea-id", name: "Deep Sea" },
{ id: "deep-sea-2", sourceId: "copy-id", name: "Deep Sea" },
{ id: "shallow", sourceId: "light-id", name: "Shallow" },
]);
expect(pkg.files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
+27
View File
@@ -73,6 +73,14 @@ mod imp {
wit::ui::remove_status_item(id)
}
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
wit::ui::copy_to_clipboard(text).map_err(error)
}
pub fn open_url(url: &str) -> Result<(), Error> {
wit::ui::open_url(url).map_err(error)
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
wit::storage::get(key).map_err(error)
}
@@ -152,6 +160,10 @@ mod imp {
wit::vcs::fetch(p).map_err(error)
}
pub fn vcs_remotes(p: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
wit::vcs::remotes(p).map_err(error)
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(error)
}
@@ -339,6 +351,11 @@ impl Project {
imp::vcs_status(&self.inner)
}
/// Configured remotes (`(name, fetch_url)`), in order. Needs `vcs.read`.
pub fn vcs_remotes(&self) -> Result<Vec<(String, String)>, Error> {
imp::vcs_remotes(&self.inner)
}
/// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`].
pub fn vcs_fetch(&self) -> Result<(), Error> {
imp::vcs_fetch(&self.inner)
@@ -528,6 +545,16 @@ pub fn remove_status_item(id: &str) {
imp::remove_status_item(id)
}
/// Copies `text` to the system clipboard. Needs `clipboard`.
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
imp::copy_to_clipboard(text)
}
/// Opens an `http://` or `https://` URL in the user's default browser.
pub fn open_url(url: &str) -> Result<(), Error> {
imp::open_url(url)
}
/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of
/// the session (also after the instance is reclaimed: an event activates the
/// plugin again). Needs `events` (`events.input` for `ActionPerformed`).
+6 -6
View File
@@ -89,7 +89,7 @@ pub mod providers_bindings {
export_macro_name: "__export_editor_providers_world",
default_bindings_module: "openidea_plugin::providers_bindings",
with: {
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
},
});
}
@@ -106,16 +106,16 @@ pub mod views_bindings {
export_macro_name: "__export_views_world",
default_bindings_module: "openidea_plugin::views_bindings",
with: {
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.0.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.0.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.0.0": crate::bindings::openidea::plugin::invocation,
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
"openidea:plugin/workspace@1.1.0": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/editor@1.1.0": crate::bindings::openidea::plugin::editor,
"openidea:plugin/invocation@1.1.0": crate::bindings::openidea::plugin::invocation,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.0";
pub const API_VERSION: &str = "1.1";
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
/// it is disabled, crashes or is reclaimed when idle).
+64 -2
View File
@@ -63,8 +63,11 @@ struct State {
subscriptions: BTreeSet<EventKind>,
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
fetches: u32,
fetch_error: Option<Error>,
clipboard: Option<String>,
opened_urls: Vec<String>,
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
secrets: BTreeMap<String, String>,
net: Option<NetHandler>,
@@ -96,8 +99,17 @@ pub struct MockHost {
}
/// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`.
const ALL: &[&str] =
&["project.read", "editor.read", "editor.write", "vcs.read", "vcs.write", "storage", "events", "events.input"];
const ALL: &[&str] = &[
"project.read",
"editor.read",
"editor.write",
"vcs.read",
"vcs.write",
"storage",
"events",
"events.input",
"clipboard",
];
impl MockHost {
/// Installs a mock granting the usual capabilities, with project `/project`.
@@ -197,6 +209,21 @@ impl MockHost {
self.state.borrow_mut().vcs = status;
}
/// Remotes `vcs-remotes` returns for every project.
pub fn set_vcs_remotes(&self, remotes: &[(&str, &str)]) {
self.state.borrow_mut().remotes = remotes.iter().map(|&(n, u)| (n.to_owned(), u.to_owned())).collect();
}
/// Last text copied with [`crate::host::copy_to_clipboard`].
pub fn clipboard(&self) -> Option<String> {
self.state.borrow().clipboard.clone()
}
/// URLs opened with [`crate::host::open_url`], in order.
pub fn opened_urls(&self) -> Vec<String> {
self.state.borrow().opened_urls.clone()
}
/// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = error;
@@ -350,6 +377,25 @@ pub mod current {
with(|s| s.status.remove(id));
}
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
with(|s| {
require(s, "clipboard")?;
s.clipboard = Some(text.to_owned());
Ok(())
})
}
pub fn open_url(url: &str) -> Result<(), Error> {
let trimmed = url.trim();
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
return Err(Error::Invalid(format!("{url:?}: expected an http:// or https:// URL")));
}
with(|s| {
s.opened_urls.push(trimmed.to_owned());
Ok(())
})
}
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
with(|s| {
require(s, "storage")?;
@@ -496,6 +542,13 @@ pub mod current {
})
}
pub fn vcs_remotes(_: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
with(|s| {
require(s, "vcs.read")?;
Ok(s.remotes.clone())
})
}
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| {
let cap = match kind {
@@ -680,6 +733,13 @@ mod tests {
assert_eq!(host::storage::keys("").unwrap(), ["k"]);
host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None });
assert_eq!(mock.status_items()["s"].text, "x");
mock.set_vcs_remotes(&[("origin", "[email protected]:o/r.git")]);
assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "[email protected]:o/r.git".into())]);
host::copy_to_clipboard("copied").unwrap();
assert_eq!(mock.clipboard().as_deref(), Some("copied"));
host::open_url("https://example.com/a").unwrap();
assert_eq!(mock.opened_urls(), ["https://example.com/a"]);
assert!(matches!(host::open_url("file:///etc/passwd"), Err(Error::Invalid(_))));
{
let task = host::begin_task("Work", true);
task.report(1, Some(2), None);
@@ -709,6 +769,8 @@ mod tests {
assert_eq!(editor.apply_edits(1, &[]), Err(Error::Denied("editor.write".into())));
assert_eq!(host::storage::get("k"), Err(Error::Denied("storage".into())));
assert_eq!(host::subscribe(EventKind::ActionPerformed), Err(Error::Denied("events.input".into())));
assert_eq!(mock.invocation().project().unwrap().vcs_remotes(), Err(Error::Denied("vcs.read".into())));
assert_eq!(host::copy_to_clipboard("x"), Err(Error::Denied("clipboard".into())));
}
#[test]
@@ -0,0 +1,54 @@
package openidea:[email protected];
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
use types.{error, extension};
record action-performed {
/// Action id (`editor.duplicate-line`).
action: string,
title: string,
/// How the user started it: `keyboard`, `menu`, `toolbar`,
/// `context-menu`, `search` or `other` (more may be added).
source: string,
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
shortcut: option<string>,
}
variant event {
file-opened(string),
file-saved(string),
editor-focused(option<string>),
action-performed(action-performed),
vcs-changed(string),
scheduled,
/// A kind added after 1.0 (subscribed to by its name).
other(extension),
}
/// Delivers `kind` events from now on, for the rest of the session (also
/// across idle reclaim: an event activates the plugin again). Declare
/// `activation = ["on-event:<kind>"]` to get them before first use.
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
/// changed), `vcs-changed` (a repository's state changed: commit,
/// checkout, fetch…), `action-performed` (an IDE action ran) and
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: string) -> result<_, error>;
unsubscribe: func(kind: string);
}
/// Long-running work with a progress indicator. While a task is open, the
/// call that began it may run for the background budget (60 s) instead of 5 s.
interface tasks {
resource task {
/// `done` of `total` units (`none`: indeterminate), with a short status.
report: func(done: u32, total: option<u32>, message: option<string>);
/// The user cancelled it: stop soon and return.
is-cancelled: func() -> bool;
}
/// Starts a task shown in the status bar; dropping it ends it.
begin: func(title: string, cancellable: bool) -> task;
}
@@ -0,0 +1,75 @@
package openidea:[email protected];
interface lifecycle {
use types.{context, error};
/// Called once after the instance is created: subscribe to events and
/// restore state from `storage`. Contributions (commands, languages,
/// themes, …) are declared in plugin.toml, not registered here.
/// An error fails the activation (shown on the plugin's page).
activate: func(ctx: context) -> result<_, error>;
/// Called before the instance is dropped (disable, uninstall, idle reclaim).
deactivate: func();
}
interface commands {
use types.{error};
use invocation.{invocation};
/// Runs one of the plugin's commands. Returns an optional message.
/// `cancelled` (the user gave up) and `denied` (a capability is missing)
/// are not counted as the plugin failing.
run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, error>;
}
interface event-handler {
use events.{event};
use invocation.{invocation};
/// A batch of subscribed events, oldest first, from one window (`ctx`).
handle: func(events: list<event>, ctx: borrow<invocation>);
}
/// Optional export (world `plugin-syntax`): syntax of the plugin's own
/// languages, for languages the IDE has no grammar for. The IDE calls it
/// with the whole document; results are flat. Offsets are UTF-8 byte offsets
/// into `text` (the IDE converts them); lines are 0-based.
interface syntax-provider {
/// One highlighted range. `kind` is a highlight category: keyword, string,
/// string-special, escape, regexp, atom, number, constant, comment,
/// doc-comment, function, function-call, builtin, type, namespace,
/// variable, self-variable, property, attribute, macro, label, tag,
/// operator, punctuation. Unknown kinds are ignored; ranges must be
/// sorted and not overlap.
record token {
start: u32,
end: u32,
kind: string,
}
/// A symbol of the structure view. `parent` is the index of the enclosing
/// symbol in the same list (which must come first).
record symbol {
name: string,
detail: option<string>,
/// class, interface, struct, enum, function, method, constructor,
/// field, property, variable, constant, module, namespace, event, …
kind: string,
start: u32,
end: u32,
name-start: u32,
name-end: u32,
parent: option<u32>,
}
/// A foldable line range. `kind`: region, comment or imports.
record fold {
start-line: u32,
end-line: u32,
kind: string,
}
highlights: func(language: string, text: string) -> list<token>;
outline: func(language: string, text: string) -> list<symbol>;
folding: func(language: string, text: string) -> list<fold>;
}
@@ -0,0 +1,38 @@
package openidea:[email protected];
/// Writes to the IDE log, tagged with the plugin id.
interface log {
use types.{log-level};
log: func(level: log-level, message: string);
}
interface ui {
use types.{notification-level};
record status-item {
text: string,
tooltip: option<string>,
/// One of the plugin's commands, run when the item is clicked.
command: option<string>,
}
/// Shows a notification balloon in the window the current command or
/// event came from (every window when there is none).
show-notification: func(level: notification-level, title: string, body: string);
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
}
/// Persistent key-value storage of the plugin (survives restarts and idle
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
interface storage {
use types.{error};
get: func(key: string) -> result<option<string>, error>;
set: func(key: string, value: string) -> result<_, error>;
delete: func(key: string) -> result<_, error>;
/// Keys starting with `prefix`, sorted.
keys: func(prefix: string) -> result<list<string>, error>;
}
@@ -0,0 +1,96 @@
package openidea:[email protected];
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range, location};
enum severity {
error,
warning,
info,
hint,
}
/// Another place that explains a diagnostic (the earlier declaration, say).
record related-information {
location: location,
message: string,
}
record diagnostic {
range: range,
severity: severity,
message: string,
/// Rule or check id (`LineLength`).
code: option<string>,
/// The tool that found it, when not the plugin itself (`checkstyle`).
source: option<string>,
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
tags: list<string>,
related: list<related-information>,
}
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
/// an open project); an empty list clears them. Requires `project.read`.
publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>;
}
/// HTTP(S) to the hosts granted with `net:<host>`.
interface net {
use types.{error};
record header {
name: string,
value: string,
}
record request {
method: string,
/// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`.
url: string,
/// `{{secret:<name>}}` in a value is replaced by the plugin's secret
/// `name` on the way out (requires `secrets`); the plugin never sees it.
headers: list<header>,
body: option<list<u8>>,
/// Default 30 s; the call's own budget applies too.
timeout-ms: option<u32>,
}
record response {
status: u16,
headers: list<header>,
/// At most 8 MiB.
body: list<u8>,
}
/// Fails with `denied` for a host the plugin may not reach, `cancelled`
/// when a newer request replaced the call or the user cancelled.
fetch: func(req: request) -> result<response, error>;
}
/// Secrets the user stored for the plugin (Settings › Plugins, kept in the
/// OS keychain). Plugins only use them through `{{secret:<name>}}` in
/// `net` requests. Requires `secrets`.
interface secrets {
is-set: func(name: string) -> bool;
}
/// One-off runs of programs granted with `process:<program>` (the file the
/// user approved, checked before every run).
interface process {
use types.{error};
use workspace.{project};
record output {
/// `none` when killed by a signal.
status: option<s32>,
/// At most 4 MiB each (the rest is dropped).
stdout: string,
stderr: string,
}
/// Runs `program` with `args` in `cwd`'s root (else the plugin's data
/// directory) and waits; cancelled with the task or call. Call it inside
/// a task for anything slow.
run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
}
@@ -0,0 +1,67 @@
/// OpenIDEA plugin API, version 1.0. Once released, 1.x only adds (new
/// interfaces, new optional exports; plugins/README.md, "Evolving the plugin
/// API"); a 2.0 keeps 1.0 running through an adapter in the host.
///
/// A plugin is a WebAssembly component targeting the `plugin` world (or
/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:[email protected];
world plugin {
import log;
import ui;
import storage;
import workspace;
import editor;
import invocation;
import vcs;
import events;
import tasks;
import diagnostics;
import net;
import secrets;
import process;
import views;
import settings;
export lifecycle;
export commands;
export event-handler;
}
/// A plugin that also provides syntax for its languages.
world plugin-syntax {
include plugin;
export syntax-provider;
}
/// A plugin with editor providers (`[[contributes.providers]]`).
world plugin-providers {
include plugin;
export providers;
}
/// A plugin with views or custom editors (`[[contributes.views]]`,
/// `[[contributes.custom-editors]]`).
world plugin-views {
include plugin;
export view-provider;
}
/// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component).
world syntax {
export syntax-provider;
}
/// Only the `providers` export, generated next to `plugin` like `syntax`.
world editor-providers {
export providers;
}
/// Only the `view-provider` export, generated next to `plugin` like `syntax`.
world views-provider {
export view-provider;
}
@@ -0,0 +1,81 @@
package openidea:[email protected];
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
/// on the editing path: a result later than ~50 ms is dropped, a call still
/// running at 500 ms is interrupted.
interface providers {
use types.{error, position, range, text-edit, location};
/// The document a request is about.
record document {
path: string,
language: string,
version: u64,
text: string,
}
record completion-item {
label: string,
detail: option<string>,
/// Icon and sort group: text, method, function, constructor, field,
/// variable, class, interface, module, property, unit, value, enum,
/// keyword, snippet, color, file, reference, folder, enum-member,
/// constant, struct, event, operator or type-parameter. Unknown kinds
/// show no icon.
kind: option<string>,
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
insert-text: string,
snippet: bool,
/// Replaced on accept; `none`: the word at the caret.
replace: option<range>,
sort-text: option<string>,
/// Matched against what was typed instead of `label`.
filter-text: option<string>,
documentation: option<string>,
/// Applied with the item elsewhere in the document (an import).
additional-edits: list<text-edit>,
}
record inline-suggestion {
insert-text: string,
/// Replaced range; `none` inserts at the caret.
replace: option<range>,
}
record file-edit {
path: string,
edits: list<text-edit>,
}
record code-action {
title: string,
/// `quickfix`, `refactor`, …
kind: option<string>,
preferred: bool,
edits: list<file-edit>,
/// The plugin's own reference: with it and no `edits`, the edits are
/// computed by `resolve-code-action` once the user picks the action.
data: option<string>,
}
record line-marker {
line: u32,
/// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`.
icon: option<string>,
tooltip: string,
/// Opened on click.
target: option<location>,
}
completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>;
/// Ghost text at the caret (typing replaces an older request: it is cancelled).
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;
}
@@ -0,0 +1,73 @@
package openidea:[email protected];
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
interface types {
/// Why a host call failed. A capability that is not granted is reported
/// as `denied`, so the plugin can degrade (0.1 trapped instead).
variant error {
/// Not granted; the payload names the capability (`editor.write`).
denied(string),
/// The document changed since the version the edit was made for.
stale,
not-found(string),
invalid(string),
/// Cancelled by the user, or the plugin is being stopped.
cancelled,
failed(string),
}
enum log-level {
trace,
debug,
info,
warn,
error,
}
enum notification-level {
info,
warning,
error,
}
record position {
line: u32,
character: u32,
}
record range {
start: position,
end: position,
}
record text-edit {
range: range,
text: string,
}
/// A range in a file (absolute path).
record location {
path: string,
range: range,
}
/// A case added after 1.0, carried by the `other` case of a variant that
/// grows (events, view bodies, view events, form fields): its kind and its
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
/// fallback; the SDKs decode the kinds they know.
record extension {
kind: string,
data: string,
}
/// Passed to `lifecycle.activate`.
record context {
plugin-id: string,
plugin-version: string,
/// Version of this API the host implements, e.g. "1.0".
api-version: string,
/// Capabilities granted to the plugin (`editor.write`, `process:git`, …).
capabilities: list<string>,
}
}
@@ -0,0 +1,154 @@
package openidea:[email protected];
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
/// `[[contributes.customEditors]]`. The plugin describes what to show; the
/// IDE renders it with its own components (theme, keyboard, accessibility)
/// and sends the user's actions back as `view-event`s. Nothing of the plugin
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
/// frame that can only exchange messages with the plugin.
interface view-types {
use types.{extension};
record button {
id: string,
label: string,
/// An IDE icon name (`refresh`, `add`, `remove`, `run`, …).
icon: option<string>,
enabled: bool,
}
/// A tree node; `parent` is the key of an earlier item (none: a root).
record tree-item {
key: string,
parent: option<string>,
label: string,
icon: option<string>,
/// Grey text after the label.
detail: option<string>,
/// Shows an expander even before its children are known
/// (`expand` asks for them).
expandable: bool,
expanded: bool,
}
record list-item {
key: string,
label: string,
icon: option<string>,
detail: option<string>,
}
variant field-value {
text(string),
multiline(string),
checkbox(bool),
/// The options and the chosen one.
choice(tuple<list<string>, string>),
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
other(extension),
}
record field {
key: string,
label: string,
value: field-value,
}
/// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE
/// asks for the rows it shows (`viewport`), so large tables stay cheap.
record table {
columns: list<string>,
total-rows: u32,
first-row: u32,
rows: list<list<string>>,
editable: bool,
}
variant body {
/// A centred message (empty state, errors).
message(string),
tree(list<tree-item>),
%list(list<list-item>),
form(list<field>),
table(table),
/// HTML shown in a sandboxed frame: no scripts from elsewhere, no
/// network, no IDE API. Its script talks to the plugin with
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
html(string),
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
other(extension),
}
record view {
toolbar: list<button>,
body: body,
/// A line below the body (counts, last refresh…).
status: option<string>,
}
/// The table rows the IDE shows.
record viewport {
first-row: u32,
rows: u32,
}
record cell-edit {
row: u32,
column: u32,
value: string,
}
variant view-event {
/// A toolbar button.
button(string),
/// Double click / Enter on a tree or list item.
activate(string),
select(string),
expand(string),
collapse(string),
/// A form field changed: key and new value (`true`/`false` for checkboxes).
field(tuple<string, string>),
cell(cell-edit),
/// Insert an empty row before this one (the row count: append).
insert-row(u32),
delete-rows(list<u32>),
/// From the `html` body's script.
message(string),
/// An event added after 1.0, from a body or field added after 1.0.
other(extension),
}
}
/// What a plugin's code can ask of its views.
interface views {
use types.{error};
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
refresh: func(view: string);
/// Sends a message to the script of a view's `html` body.
post-message: func(view: string, message: string) -> result<_, error>;
/// Opens a terminal tab in the window the call came from, running
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
/// (project over global) or the schema's default.
interface settings {
/// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown.
get: func(key: string) -> option<string>;
}
/// Optional export (world `plugin-views`): content of declared views and
/// custom editors. For a custom editor, `ctx.editor()` is its document.
interface view-provider {
use view-types.{view, viewport, view-event};
use invocation.{invocation};
use types.{error};
render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>;
handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>;
}
@@ -0,0 +1,90 @@
package openidea:[email protected];
interface workspace {
use types.{error};
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
root: func() -> result<string, error>;
/// Reads a UTF-8 text file inside the project (absolute, or relative
/// to the root); paths leaving the root are refused. Unsaved editor
/// contents win over the disk. Requires `project.read`.
read-file: func(path: string) -> result<string, error>;
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
/// at most `limit`, as absolute paths. Ignored and excluded files are
/// skipped. Requires `project.read`.
find-files: func(glob: string, limit: u32) -> result<list<string>, error>;
}
/// Projects open in any window. Requires `project.read`.
projects: func() -> result<list<project>, error>;
}
interface editor {
use types.{error, range, text-edit};
record document {
/// Grows with every change; edits name the version they were made for.
version: u64,
text: string,
}
/// A file open in an editor.
resource text-editor {
/// Absolute path of the file.
path: func() -> string;
/// Language id (`rust`, `solidity`, …).
language: func() -> string;
/// Current version and text. Requires `editor.read`.
document: func() -> result<document, error>;
/// Selections when the command was invoked (a cursor is an empty
/// range). Requires `editor.read`.
selections: func() -> result<list<range>, error>;
/// Applies `edits` as one undoable change, only if the document is
/// still at `version` (else `stale`, and nothing changes). Ranges are
/// in that version and must not overlap. Returns the new version.
/// Requires `editor.write`.
apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>;
}
}
/// Where a command or event comes from.
interface invocation {
use workspace.{project};
use editor.{text-editor};
resource invocation {
/// The window's project.
project: func() -> option<project>;
/// The window's focused editor.
editor: func() -> option<text-editor>;
}
}
/// Git, for the repository containing a project.
interface vcs {
use types.{error};
use workspace.{project};
record repository-status {
/// Absolute path of the working tree root.
root: string,
/// `none` when HEAD is detached.
branch: option<string>,
commit: option<string>,
upstream: option<string>,
ahead: u32,
behind: u32,
/// Merge / rebase / cherry-pick in progress.
operation: option<string>,
/// Files with staged or unstaged changes.
changed-files: u32,
}
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
}
+8 -2
View File
@@ -140,12 +140,18 @@ fn released_apis_are_unchanged() {
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was.
// A world may gain imports and exports; everything else stays as it was. Items are
// compared without their `@version`: a later minor names them `@1.1.0`, and wasmtime
// resolves the release's `@1.0.0` to it.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end].split(", ").filter(|x| !x.is_empty()).map(str::to_owned).collect()
s[start..end]
.split(", ")
.filter(|x| !x.is_empty())
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
.collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface lifecycle {
use types.{context, error};
+6 -2
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -8,7 +8,7 @@ interface log {
}
interface ui {
use types.{notification-level};
use types.{error, notification-level};
record status-item {
text: string,
@@ -23,6 +23,10 @@ interface ui {
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
/// Copies `text` to the system clipboard. Requires `clipboard`.
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
+1 -1
View File
@@ -7,7 +7,7 @@
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
world plugin {
import log;
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+3 -1
View File
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface workspace {
use types.{error};
@@ -84,6 +84,8 @@ interface vcs {
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more