Compare commits

..
Author SHA1 Message Date
l0ng-ai 3d6f648de3 test(plugins): use workbench IPC paths in host fixtures 2026-11-05 17:03:15 +08:00
l0ng-ai ad7fc70df5 fix(checkstyle): match native Windows report paths 2026-11-05 16:52:39 +08:00
l0ng-ai ffe14e22cd fix(plugins): hand plugins paths in one form on Windows
Plugins got the project root, editor path, provider documents, code action edits, vcs-status roots and find-files results as D:\… while event paths from the UI use /, so Checkstyle could not find the project of a saved file. The host now converts every path it hands to plugins with ipc_path::to_ipc.
2026-11-05 16:00:21 +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
l0ng-ai 90e336761d fix(ui): don't finish a press the engine releases itself on macOS (#1463)
The shim for WebKit's reordered taps (#1072) took a press stamped at the same time as the
release before it for a stale one, and finished it with a synthetic pointerup, mouseup, click and
dblclick a tick later, even after the engine had delivered that press's own release. A fast
double-click whose second press shared the first release's time step was activated twice: on
macOS, double-clicking a Gradle task started it twice and the second start asked to stop the
first ("is not allowed to run in parallel"), which is why the linked Gradle E2E failed on main.

Only a press stamped before the release is stale now, and a press the engine releases before the
tick is left alone.
2026-11-05 13:33:35 +08:00
l0ng-ai 73be53b6d9 fix(macos): add a project opened over a fullscreen tab group as a tab (#1467)
A project-tab group goes fullscreen through simple fullscreen, which is_fullscreen() doesn't report, so with the default "prefer tabs in full screen only" a project opened over a fullscreen group came up as a floating window. The new-project tab target now also counts simple fullscreen.

Fixes #1438
2026-11-05 13:29:49 +08:00
l0ng-ai 2760c91bde feat(welcome): group recent projects (#1465)
Welcome › Projects can put recent projects into named groups: Move to Group (other groups, New Group…, Remove from group) on a project, Rename / New / Remove Group on a group (removing keeps its projects). Groups are listed first, fold with click, Enter or ←/→, and searching lists matches without groups. Groups live in recent-groups.json beside recent.json; grouped projects are exempt from the 30-project cap and leave their group when removed from recent projects.

Fixes #1420
2026-11-05 13:24:15 +08:00
l0ng-ai a5fb380960 fix(ui): install the reordered-tap shim only in WebKit (#1462)
The shim for WebKit bug 219670 also ran in Chromium on macOS (the e2e browser), where a scripted double-click stamps its second press like the release before it; the shim took that for a reordered tap and dispatched an extra click and dblclick, activating a tree row twice and timing out e2e/linkedGradle.spec.ts on a parallel-run prompt. It is now installed only in WebKit.
2026-11-05 13:09:56 +08:00
Mariano Dupontandmarianomd 8dedf5e73b fix(themes): preserve language-specific ICLS styles (#1457)
Importing a PhpStorm .icls applied Java/Kotlin keys and PHP-only colours to every language, so PHP variables, strings and punctuation came out wrong. Java, Kotlin and PHP attributes are now kept per language (Color Scheme lists them under their own nodes, and editors scope them by data-language), explicit attribute inheritance is resolved, an explicit TextAttributes value resets omitted bold/italic, the light/dark parent follows the scheme's background, and PHP classes, method declarations, fields, brackets and separators get their own tags. Selecting a language attribute in Settings previews that language's colours.

Co-authored-by: marianomd <[email protected]>
2026-11-05 12:59:38 +08:00
Nguyen Thinh 75c4ca0be8 feat(database): search and pagination on result query (#1432)
Adds a search bar to query result grids (match case / words / regex, previous/next occurrence, Filter rows) and a pager for console results (first/previous/next/last, page size). Later pages re-run the read-only query with a skip, which every driver reads past without decoding, so only one page crosses IPC; Last counts rows with the statement wrapped so a trailing line comment can't swallow the closing parenthesis.

Closes #1431

Co-authored-by: kidp2h <[email protected]>
2026-11-05 12:54:15 +08:00
l0ng-ai 7856fabbfe fix(editor): keep selected text visible over highlighted ranges (#1454)
The selection is drawn in a layer behind the text, so any range with its own background (find
matches, warnings in light schemes, usages, semantic backgrounds) hid it: selected text there
kept its yellow or other highlight. Selected text is now also marked in the innermost span with
the selection colour, in text editors, notebook cells and the VCS viewers.
2026-11-05 12:50:25 +08:00
l0ng-ai 4a90f124c6 fix: reopen long agent conversations quickly and pack the Git log graph (#1453)
Agent conversations: an editor tab mounts only while it is active, so switching back to a
task rebuilt the whole conversation, parsed and sanitized every message again, and fetched
all entries again, which replaced them and rendered everything a second time. Loaded entries
are already kept current by the task's events, so they are no longer fetched again; rendered
Markdown is cached by text; and a conversation opened at its end renders its last 40 entries
first and the earlier ones in batches behind it (all at once when a scroll position or Find
needs them).

Git log: with branch-aware layout (#1076) a commit's lane was its branch's global layout
index, so a repository with many branches drew lanes dozens of columns apart, joined by
diagonals across the row. Each row boundary and node level is now packed side by side in
layout order; adjacent rows share their boundary, so lines stay joined. The column header row
is hidden by default and can be shown from View Options › Columns › Column Headers, where
columns are resized and reordered.
2026-11-05 12:50:20 +08:00
l0ng-ai bb4e2db47f fix(python): name the project interpreter in the status bar wherever it lives (#1461)
An interpreter that detection doesn't look for (a virtualenv in a subfolder such as backend/.venv, or one set by path) was missing from sdk_detect, so the status bar and interpreter menu showed its raw path. sdk_detect for Python now adds the project's interpreter when detection didn't find it, named by the same rules, and a project-local .venv in a subfolder is named after that folder.

Fixes #1428
2026-11-05 12:49:13 +08:00
l0ng-ai fec391b9c1 fix(agents): keep registry installs running after Settings closes (#1460)
Closing Settings while an ACP Registry agent was installing cancelled the install, and Install was offered again while a previous attempt could still hold the agent's folder. Installs now outlive the page as a background task of the project windows (stoppable from the status bar); the backend refuses a second install of the same agent and marks it installing in the list, which a reopened page follows until it ends.

Fixes #1430
2026-11-05 12:43:42 +08:00
ppxbandppxb 43af99d3ec feat(app): ask before closing with running Run/Debug processes (#1445)
Closing a window, closing or switching a project, or quitting while Run/Debug sessions are still running now asks before terminating them (Terminate / Cancel / Don't ask again, sharing run.confirmTerminate with the Run tab prompt). Run publishes its live sessions through a RunningProcessesProvider contribution, so the close guard collects them without importing the run feature; the prompt comes before the unsaved-changes step so cancelling never discards edits.

Closes #1433

Co-authored-by: ppxb <[email protected]>
2026-11-05 12:34:35 +08:00
l0ng-ai 3e88fa1f39 fix(http-client): list Spring endpoints on Windows (#1456)
Spring route sources came back from the host with native separators, so on Windows every source failed the prefix check against the forward-slash project root and Search Everywhere showed no endpoints. RouteSource.path is now an IpcPath and the index compares by path segment; the add-to-ignore result also returns IPC paths.

Fixes #1422
2026-11-05 12:30:25 +08:00
DomKingandDomKing 6b89984863 fix(terminal): deliver the IME commits xterm drops on macOS (#1441)
IMEs that commit without composition events (Doubao on macOS) inject their text asynchronously while the keyCode-229 keydown that started the commit is still pending. xterm ignores an insertText event while a keydown is pending, and its deferred textarea diff misses text that lands after its snapshot, so a fast run of digits lost characters (1234567890 arrived as 138). On macOS the terminal now delivers those commits itself (only commits during a pending IME keydown, and composition input without compositionstart); real compositions, keys xterm already sent and input without an IME keydown stay xterm's. Linux and Windows keep the fcitx5 ImeCommitGate. Keystrokes are written to the PTY as they come instead of per animation frame, and those typed while the shell is still starting are sent once it exists.

Co-authored-by: DomKing <[email protected]>
2026-11-05 12:22:42 +08:00
黄贵波 a083571537 fix(editor): let the overview lane own the editor's vertical scrollbar (#1449)
The overview lane sat over the scrollbar with pointer-events: none and is 12px wide, the native scrollbar 10px: a press on the lane's edge, or where marks covered the thumb, reached the text layer and dragged a text selection instead of scrolling. The lane now owns the vertical scrollbar, as IntelliJ's error stripe does: the native one is hidden, the lane's thumb drags (with the same geometry it is drawn with), and a press above or below the thumb pages the editor. Marks still jump to their line.

Co-authored-by: IsGuibo <[email protected]>
2026-11-05 12:19:47 +08:00
l0ng-ai d3cb7c14ca feat(maven): add POMs outside the root reactor as Maven projects (#1446)
feat(maven): add POMs outside the root reactor as Maven projects

Add Maven Projects only re-linked POMs Maven had already discovered: picking a pom.xml inside the
project that the root POM doesn't list as a module (an independent build kept out of the reactor)
closed the dialog and changed nothing. Such POMs are now kept in the project's Maven settings
(linkedProjects) and loaded as independent top-level projects, after the root reactor, by the
static model, the sync, the Java language server import and the content roots; each runs its goals
in its own directory. A picked POM that the root reactor lists stays its module. The dialog takes
several POMs at once, and unlinking a linked project drops the link instead of hiding it.
2026-11-05 12:01:03 +08:00
yoo hyeong chan 0dfdbbc323 feat(gradle): link and unlink independent Gradle builds (#1413)
Opening a folder of independent Gradle builds selected only the first build. Link Gradle Projects… (pick a build's settings or build file) and Unlink on a build's root let an ordinary project window manage several independent builds without changing their scripts. The build model holds a list of Gradle builds; each keeps its own wrapper, working directory, environment, task routing and sync cache, keyed by the owning build directory. The links persist in the project's Gradle settings (linkedProjects); unlinking leaves files and saved run configurations alone. Existing includeBuild support is unchanged, and an explicitly linked build takes precedence over a composite that also includes it. Workspace windows keep Link/Unlink disabled.

Co-authored-by: gudcks0305 <[email protected]>
2026-11-05 11:23:39 +08:00
l0ng-ai 682fee633d fix(vfs): report files deleted on macOS again (#1435)
fix(vfs): report files deleted on macOS again

Since the watcher queues raw notify events (#1412), deleting a file on macOS was never reported, so
its symbols stayed in the index and the tree kept it (the project smoke tests failed for every
language). FSEvents delivers a deleted file's coalesced history flags as create, remove, then
modify events. Create + remove merged into a transient path, the following modify turned it back
into a creation, and a creation of a path that no longer exists is dropped. A modify no longer
revives a transient path; only a create does, and the disk decides as before.
2026-11-05 11:14:38 +08:00
l0ng-ai dce57ca83a fix(tools): find Python on Windows without python3.exe (#1427)
* fix(tools): find Python on Windows without python3.exe

Installing debugpy on Windows failed with "cannot run python3.exe … (os error 3)": the tool
manager looked for `python3` first, and on Windows that only finds the App Execution Alias in
WindowsApps, which can't start without the Store's Python. Windows installs `python.exe` and the
`py` launcher instead. On Windows the interpreter is now looked up as `python`, `py`, then
`python3`, and a WindowsApps alias only counts when it runs. The debugpy adapter's default
interpreter is `python` on Windows too. macOS and Linux keep `python3` first.

* fix(tools): keep searching past broken Python aliases
2026-11-05 10:59:03 +08:00
FizzyKey8 b674d0f2e8 feat(i18n): add Spanish translations (#1426)
Spanish (es, with es-MX / es_MX system locales) joins the UI languages under Settings › Appearance & Behavior › System Settings › Language and Region, with the key catalog (locales/es.json) and the source-text catalog (locales/text/es.json, 6180 entries).

Co-authored-by: FizzyKey8 <[email protected]>
2026-11-05 10:49:26 +08:00
Lee Dong Geon 001381459e fix(themes): match IntelliJ's gutter, selected tab border and completion colours on import (#1424)
Themes imported from IntelliJ drew a lighter gutter strip and kept the built-in blue for the selected tab's border and completion matches. The converter read the classic UI's GUTTER_BACKGROUND, where the new UI paints EDITOR_GUTTER_BACKGROUND or else the editor's background; it read EditorTabs.underlineColor, where Islands borders the selected tab with underlinedBorderColor; and it didn't map CompletionPopup.matchForeground. Scheme attributes written as `<value />` (no colours, not inherited) now show as plain text instead of keeping the built-in colours.

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 10:48:21 +08:00
ntbang0901 38ac62f1a4 fix(ui): deliver the click of a tap WebKit reorders on macOS (#1072)
On macOS, while an editable element has focus and a third-party input
method (reproduced with WeChat's WeType) is active, WebKit can deliver a
tap as pointerup, mouseup, pointerdown, mousedown (WebKit bug 219670).
The tap's click is lost, and because the engine still considers the
button down, the next tap starts with a bare mousedown and no
pointerdown. Taps on tree rows, tabs and tool window buttons needed a
second tap.

A press stamped no later than the pointerup just before it is a
reordered tap: the shim finishes it with a synthetic pointerup, mouseup
and click (and dblclick for a double) after the page's handlers ran, and
dispatches the skipped pointerdown when a primary mouse press arrives
with no pointer down. The next tap's own click is kept. Installed on
macOS only; untrusted events and other pointer types are ignored.

Verified in a WKWebView with WeType, alternating taps on two targets:
main 19/20 taps clicked their target, with this change 20/20, every press
ran the pointerdown handlers and setPointerCapture worked; with the ABC
layout the shim dispatches nothing.

Refs #760, #724

Co-authored-by: ntbang0901 <[email protected]>
2026-11-05 10:30:43 +08:00
ntbang0901 29c54c243a fix(titlebar): start window drags on macOS only for a press that is still held (#1074)
On macOS, with tap to click and a third-party input method active, WebKit
can deliver a tap's mousedown after the button is already up (WebKit bug
219670). Tauri's drag-region script starts the native drag on any
mousedown, so AppKit ran its drag loop with no release left to end it and
the window followed the pointer until the next click.

On macOS the title bar now uses its own drag region: a drag starts only
when the mousedown has the primary button down, and the new
window_start_drag command checks NSEvent.pressedMouseButtons before
start_dragging. Double-click zoom stays on mouseup and is cancelled by
movement. The handlers are installed once per page. Windows and Linux
keep data-tauri-drag-region.

Refs #760

Co-authored-by: ntbang0901 <[email protected]>
2026-11-05 10:29:51 +08:00
321 changed files with 18399 additions and 1056 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"
+3
View File
@@ -76,6 +76,8 @@ pub struct RegistryAgent {
pub installed: Option<String>,
/// Whether [`Self::version`] is newer than the installed one.
pub update: bool,
/// An install of it is running (it goes on after the Settings page that started it closes).
pub installing: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
@@ -332,6 +334,7 @@ fn visible(
distribution,
no_checksum,
installed,
installing: false,
})
})
.collect();
+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");
}
+3 -3
View File
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
kind: "maven",
});
}
if let Some(g) = &model.gradle
if let Some(g) = model.gradle_for_module(root, &module)
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
@@ -163,11 +163,11 @@ mod tests {
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
model.gradle = Some(crate::GradleModel {
model.gradle = vec![crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
});
}];
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
+99 -18
View File
@@ -147,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
let rel = rel_str(root, dir);
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
Some(BuildSystem::Maven)
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
} else if model.gradle_for_module(root, dir).is_some()
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
@@ -162,16 +164,13 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
/// Resolves a workspace-relative or absolute directory with native path separators.
fn subdir(root: &Path, rel: &str) -> PathBuf {
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
root.join(rel).components().collect()
}
fn rel_str(root: &Path, dir: &Path) -> String {
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
}
/// Directories from `file`'s folder up to `root` (inclusive).
@@ -192,7 +191,14 @@ pub fn plan(
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
let module_dir = match file {
Some(f) => {
let dir = ancestors_within(root, f)
let scope = model
.gradle
.iter()
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
.filter(|dir| f.starts_with(dir))
.max_by_key(|dir| dir.components().count())
.unwrap_or_else(|| root.to_path_buf());
let dir = ancestors_within(&scope, f)
.find(|d| detect_dir(d, model, root).is_some())
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
Some(dir.to_path_buf())
@@ -202,7 +208,7 @@ pub fn plan(
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
@@ -270,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
}
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
let fallback;
let g = match &model.gradle {
Some(g) => g,
None => {
fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
&fallback
}
};
if module.is_none() && !model.gradle.is_empty() {
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
}
let g = module
.and_then(|m| model.gradle_for_module(root, m))
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
Ok(gradle_step(root, g, kind, module))
}
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
let prefix = module
.and_then(|m| {
let rel = rel_str(root, m);
@@ -296,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
s.args.extend(tasks);
s.env = g.env.clone();
Ok(vec![s])
vec![s]
}
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
@@ -811,6 +819,79 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
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(wrapper), "").unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps.len(), 2);
assert_eq!(all.steps[0].cwd, root.join("a"));
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").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"]);
// A directly linked build owns its module even when another composite reports it too.
let direct = model.gradle[1].projects[0].clone();
model.gradle[0].projects.push(direct);
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
let mut nested = model.gradle[1].projects[1].clone();
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").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());
}
#[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");
let external = base.join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
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(wrapper), "").unwrap();
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
};
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
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(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");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
let other = base.join("unlinked");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("build.gradle"), "").unwrap();
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
}
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
+127
View File
@@ -435,6 +435,15 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
pub fn load(root: &Path) -> Option<GradleModel> {
let dir = build_dir(root)?;
load_build(root, dir.strip_prefix(root).unwrap_or(&dir))
}
/// Loads exactly this build, without searching its child folders.
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
let dir = root.join(dir);
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
return None;
}
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let root_name =
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
@@ -455,6 +464,65 @@ pub fn load(root: &Path) -> Option<GradleModel> {
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
}
/// Validates user-selected build folders and stores a stable, deduplicated identity.
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
let root = dunce::canonicalize(root)?;
let mut out = Vec::new();
for dir in dirs {
let path = dunce::canonicalize(root.join(dir))?;
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
}
let dir = rel(&root, &path);
if !out.contains(&dir) {
out.push(dir);
}
}
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 {
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
Some(dirs) => {
let mut out = Vec::new();
for dir in dirs {
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
for dir in dirs {
if !out.contains(&dir) {
out.push(dir);
}
}
}
}
out
}
}
}
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
}
/// A build's task paths are local to it, so sync caches must be local to its identity too.
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
if build_dir.is_empty() {
return cache.to_path_buf();
}
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
cache.join("gradle").join(format!("{hash:016x}"))
}
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
pub fn script_java_version(text: &str) -> Option<u32> {
let number = |s: &str| -> Option<u32> {
@@ -616,6 +684,65 @@ 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();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
}
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
..Default::default()
};
let models = super::load_linked(root, &settings);
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["b", "a"]);
settings.linked_projects = Some(vec![]);
assert!(super::load_linked(root, &settings).is_empty());
assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
}
#[cfg(unix)]
#[test]
fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&external).unwrap();
std::fs::write(external.join("build.gradle"), "").unwrap();
std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
}
#[test]
fn script_dependencies_read_string_and_map_notations() {
let script = r#"
+16 -5
View File
@@ -126,6 +126,7 @@ fn project_path(text: &str) -> String {
/// `configuration` and returns its tree.
pub fn gradle_dependencies(
root: &Path,
model: &gradle::GradleModel,
options: &SyncOptions,
project: &str,
configuration: &str,
@@ -137,7 +138,6 @@ pub fn gradle_dependencies(
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
}
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
let mut args: Vec<String> =
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
@@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'.
let tmp = tempfile::tempdir().unwrap();
let options = SyncOptions::default();
let cancel = CancellationToken::new();
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
assert!(
gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
);
assert!(
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
);
}
#[cfg(unix)]
@@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'.
offline: true,
..Default::default()
};
let tree =
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
let tree = gradle_dependencies(
root,
&gradle::load(root).unwrap(),
&options,
":app",
"testRuntimeClasspath",
&CancellationToken::new(),
)
.unwrap();
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
assert_eq!(
std::fs::read_to_string(root.join("args")).unwrap().trim(),
+91 -16
View File
@@ -20,7 +20,9 @@ mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use gradle_deps::{GradleDependency, gradle_dependencies};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
pub use sync::{
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -38,13 +40,31 @@ pub enum BuildTool {
#[serde(rename_all = "camelCase")]
pub struct BuildModel {
pub maven: Option<MavenModel>,
pub gradle: Option<GradleModel>,
#[serde(default)]
pub gradle: Vec<GradleModel>,
}
impl BuildModel {
/// Prefer the deepest linked build owning the module over a composite including it.
pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
let module = root.join(module);
self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
let dir = root.join(&g.dir);
std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
})
}
}
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
load_linked(
root,
cache_dir,
maven_repo,
&ide_core::settings::MavenSettings::default(),
&ide_core::settings::GradleSettings::default(),
)
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
@@ -53,31 +73,66 @@ pub fn load_linked(
cache_dir: &Path,
maven_repo: Option<PathBuf>,
settings: &ide_core::settings::MavenSettings,
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = sync::maven_profiles(cache_dir);
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
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
});
let gradle = gradle::load(root).map(|mut g| {
sync::apply_gradle_cache(root, cache_dir, &mut g);
g
});
let gradle = gradle::load_linked(root, gradle_settings)
.into_iter()
.map(|mut g| {
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
g
})
.collect();
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
pub fn gradle_external_roots(
root: &Path,
cache_dir: &Path,
settings: &ide_core::settings::GradleSettings,
) -> Vec<PathBuf> {
let builds = gradle::linked_dirs(root, settings);
let dirs: Vec<String> = builds
.iter()
.flat_map(|dir| {
std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
.iter()
.map(|p| p.dir.clone())
.collect::<Vec<_>>(),
)
})
.collect();
gradle::external_roots(root, dirs.iter().map(String::as_str))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
pub fn gradle_source_roots(
root: &Path,
cache_dir: &Path,
settings: &ide_core::settings::GradleSettings,
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
let reported = gradle::linked_dirs(root, settings)
.iter()
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
.collect::<Vec<_>>();
gradle::reported_source_roots(&reported)
}
/// A library the project depends on, as `groupId:artifactId:version`.
@@ -121,7 +176,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
});
}
}
if let Some(g) = &model.gradle {
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
@@ -147,6 +202,26 @@ pub fn affects_build(rel: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&external).unwrap();
std::fs::write(external.join("settings.gradle"), "").unwrap();
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
};
assert_eq!(
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
[dunce::canonicalize(&external).unwrap()]
);
settings.linked_projects = Some(vec![]);
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -158,7 +233,7 @@ mod tests {
std::fs::write(root.join("build.gradle"), "").unwrap();
let m = load(&root, &cache, None);
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
assert_eq!(m.gradle[0].projects[0].path, ":");
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
assert!(!affects_build(Path::new("src/Main.java")));
}
@@ -205,7 +280,7 @@ mod tests {
],
..Default::default()
};
let gradle = gradle::load(root);
let gradle = gradle::load(root).into_iter().collect();
let model = BuildModel { maven: Some(maven), gradle };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
+157 -7
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> {
@@ -1065,10 +1100,9 @@ pub fn load(root: &Path) -> MavenModel {
load_with_repo(root, default_local_repo())
}
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
/// dependencies and properties are part of the model, those turned off are not.
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
load_with(root, default_local_repo(), profiles)
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1090,11 +1124,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
}
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
load_with_linked(root, local_repo, profiles, &[])
}
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out: Vec<PathBuf> = Vec::new();
for pom in linked {
let pom = normalized_pom(pom);
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
continue;
}
let path = root.join(&pom);
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
if path.is_file() && inside && !out.contains(&path) {
out.push(path);
}
}
out
}
/// Whether `root` holds a Maven build or the settings link one inside it.
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
}
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
/// dependencies and properties are part of the model, those turned off are not) and the
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
pub fn load_with_linked(
root: &Path,
local_repo: Option<PathBuf>,
profiles: &[String],
linked: &[String],
) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet<PathBuf> = HashSet::new();
// (pom, aggregated)
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
let mut tops = top_level_poms(root);
for pom in linked_poms(root, linked) {
if !tops.contains(&pom) {
tops.push(pom);
}
}
// (pom, aggregated); the root reactor and its modules come before linked POMs.
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
if model.projects.len() >= MAX_PROJECTS {
break;
@@ -1216,7 +1292,7 @@ impl UnlinkedProjects {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load(root), settings)
Self::from_model(&load_linked(root, settings), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
@@ -1495,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();
@@ -1733,6 +1840,49 @@ mod tests {
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
/// Add Maven Projects: a POM inside the project that no reactor lists becomes an
/// independent top-level project; one the root reactor lists stays its module.
#[test]
fn linked_poms_outside_the_reactor_are_top_level_projects() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
);
write(
root,
"core/pom.xml",
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
);
write(
root,
"stub/pom.xml",
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
);
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
let linked: Vec<String> =
["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into();
assert_eq!(
top(&load_with_linked(root, None, &[], &linked)),
[("".into(), false), ("core".into(), true), ("stub".into(), false)]
);
// Deeper than the folder search reaches: only the link makes it a Maven project.
let deep = tempfile::tempdir().unwrap();
write(deep.path(), "a/b/c/tool/pom.xml", "<project><artifactId>tool</artifactId></project>");
let settings = ide_core::settings::MavenSettings {
linked_projects: vec!["a/b/c/tool/pom.xml".into()],
..Default::default()
};
assert!(!has_projects(deep.path(), &Default::default()));
assert!(has_projects(deep.path(), &settings));
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
}
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
+154 -7
View File
@@ -283,7 +283,12 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load_with_profiles(root, &options.profiles);
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.profiles,
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
@@ -373,8 +378,25 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
std::fs::create_dir_all(cache_dir)?;
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
}
/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
pub fn sync_gradle_model(
root: &Path,
cache_dir: &Path,
model: &GradleModel,
options: &SyncOptions,
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut model = model.clone();
let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
std::fs::create_dir_all(&cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
@@ -389,14 +411,24 @@ pub fn sync_gradle(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = run(
let out = match run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_| {},
)?;
) {
Ok(out) => out,
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
Output { success: false, text: error.message }
}
Err(error) => return Err(error),
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
// Hash the build files of the reported projects, or the model is stale right after the sync.
gradle::merge_reported_projects(root, &mut model, &projects);
@@ -407,13 +439,128 @@ pub fn sync_gradle(
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
};
write_cache(cache_dir, GRADLE_CACHE, &cache)
write_cache(&cache_dir, GRADLE_CACHE, &cache)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
}
let wrapper = root.join("b/gradlew");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let mut models = gradle::load_linked(root, &settings);
models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
let cache = root.join("cache");
let run = |model: &GradleModel| {
sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
};
for model in &models {
run(model).unwrap();
}
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
assert!(loaded.gradle[1].sync_error.is_none());
assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
let bad_wrapper = root.join("a/gradlew");
std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
models[0].command = bad_wrapper.to_string_lossy().into_owned();
run(&models[0]).unwrap();
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
assert!(loaded.gradle[1].sync_error.is_none());
let blocked_cache = root.join("blocked-cache");
std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
assert!(
sync_gradle_model(
root,
&blocked_cache,
&models[0],
&SyncOptions::default(),
&CancellationToken::new(),
&|_| {}
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
let build = root.join(dir);
std::fs::create_dir_all(build.join("app")).unwrap();
std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(build.join("app/build.gradle"), "").unwrap();
let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
let launcher = build.join("gradlew");
std::fs::write(
&launcher,
format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
).unwrap();
std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let cache = root.join("cache");
let models = gradle::load_linked(root, &settings);
for model in &models {
let options = SyncOptions {
env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
..Default::default()
};
sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(
std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
format!("/jdk-{}", model.dir)
);
assert_eq!(
std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
);
}
let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
let model = load();
assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
.unwrap();
let model = load();
assert!(model.gradle[0].sync_error.is_none());
assert!(model.gradle[1].sync_error.is_some());
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
}
#[cfg(unix)]
#[test]
fn gradle_sync_uses_resolved_command_and_environment() {
@@ -601,7 +748,7 @@ mod tests {
write(&projects);
let first = reported_gradle_projects(&cache_dir);
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
+40 -5
View File
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
});
}
}
if let Some(gradle) = &model.gradle {
for gradle in &model.gradle {
for p in &gradle.projects {
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
out.push(WebArtifact {
id: format!("gradle:{}", p.path),
id: gradle_artifact_id(gradle, &p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
@@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
out
}
fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String {
if build.dir.is_empty() {
format!("gradle:{path}")
} else {
format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap())
}
}
pub struct WebBuild {
pub command: String,
pub args: Vec<String>,
@@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
maven_project: Some((probe, output)),
});
}
if let Some(path) = id.strip_prefix("gradle:") {
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == id) {
if let Some(legacy_path) = id.strip_prefix("gradle:") {
let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
let (g, path) = match &identity {
Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
None => (model.gradle.first(), legacy_path),
};
let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
@@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
#[cfg(test)]
mod tests {
#[test]
fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
let artifacts = super::artifacts(root, &model);
assert_eq!(artifacts.len(), 2);
assert_ne!(artifacts[0].id, artifacts[1].id);
for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
assert_eq!(plan.cwd, dir);
}
assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
}
use super::*;
#[test]
fn maven_war_modules_use_resolved_artifact_file() {
+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};
+15
View File
@@ -828,6 +828,8 @@ pub struct JavaSettings {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
/// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
pub linked_projects: Option<Vec<String>>,
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
@@ -864,6 +866,8 @@ pub enum GradleDistribution {
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
@@ -1124,6 +1128,16 @@ mod maven_linkage_tests {
mod gradle_settings_tests {
use super::*;
#[test]
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
assert_eq!(automatic.linked_projects, None);
assert_eq!(unlinked.linked_projects, Some(vec![]));
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
assert_eq!(restored.linked_projects, Some(vec![]));
}
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
@@ -1136,6 +1150,7 @@ mod gradle_settings_tests {
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
linked_projects: Some(vec!["backend".into(), "../shared".into()]),
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
+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();
}
+56 -11
View File
@@ -150,6 +150,7 @@ impl ClickhousePool {
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
}
/// `max_rows`: the rows the server may send (the skipped ones included).
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
let mut request = client
.post(self.0.endpoint.clone())
@@ -177,6 +178,17 @@ impl ClickhousePool {
}
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if transaction_statement(sql) {
return Err(transaction_error());
}
@@ -188,7 +200,7 @@ impl ClickhousePool {
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
@@ -216,7 +228,7 @@ impl ClickhousePool {
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(max_rows);
let mut reader = RowReader::new(skip, max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
@@ -309,11 +321,13 @@ impl ClickhousePool {
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
/// unparsed, so a grid limit bounds memory however large the block
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// starts the exception ClickHouse writes when a query fails mid-stream.
struct RowReader {
skip: usize,
max_rows: usize,
pending: Vec<u8>,
names: Option<Vec<String>>,
@@ -324,8 +338,17 @@ struct RowReader {
}
impl RowReader {
fn new(max_rows: usize) -> Self {
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
fn new(skip: usize, max_rows: usize) -> Self {
Self {
skip,
max_rows,
pending: Vec::new(),
names: None,
types: None,
rows: Vec::new(),
more: false,
exception: None,
}
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
@@ -356,9 +379,15 @@ impl RowReader {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
if self.names.is_some() && self.types.is_some() {
if self.skip > 0 {
self.skip -= 1;
return Ok(());
}
if self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
}
}
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
@@ -547,7 +576,11 @@ mod tests {
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(max_rows);
decode_from(body, 0, max_rows, chunk)
}
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(skip, max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
@@ -586,6 +619,18 @@ mod tests {
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
}
#[test]
fn a_later_page_skips_rows_without_keeping_them() {
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
let page = |skip, max| {
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(), more)
};
assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
assert_eq!(page(9, 2), (Vec::<String>::new(), false));
}
#[test]
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
+49 -10
View File
@@ -337,10 +337,13 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
fetch!($exec, $sql, 0usize, $max, $mode)
};
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
use futures::StreamExt;
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
let mut columns: Vec<ResultColumn> = Vec::new();
@@ -349,6 +352,7 @@ macro_rules! fetch {
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = None;
let mut to_skip: usize = $skip;
while let Some(item) = stream.next().await {
first.get_or_insert_with(Instant::now);
match item {
@@ -357,6 +361,10 @@ macro_rules! fetch {
if columns.is_empty() {
columns = result_columns(&row);
}
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= $max {
has_more = true;
break;
@@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
Ok(())
}
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
@@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
.collect();
let mut rows = Vec::new();
let mut has_more = false;
let mut to_skip = skip;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= max {
has_more = true;
break;
@@ -734,7 +748,7 @@ impl Pool {
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, max).await
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
@@ -744,11 +758,23 @@ impl Pool {
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (a later page of a result: only that page is decoded and returned).
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Pool::Clickhouse(p) = self {
return p.execute(sql, max_rows, cancel).await;
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute(self, sql, max_rows, cancel).await;
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -874,9 +900,22 @@ impl Held {
sql: &str,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(pool, sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (see [`Pool::execute_from`]).
pub async fn execute_from(
&mut self,
pool: &Pool,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Held::Clickhouse(c) = self {
return c.execute(sql, max_rows, cancel).await;
return c.execute_from(sql, skip, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
@@ -887,7 +926,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -916,7 +955,7 @@ impl Held {
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, max_rows) => r?,
r = mssql_fetch(c, sql, skip, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
+18 -4
View File
@@ -1009,7 +1009,20 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, max_rows, cancel).await;
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
pub async fn execute_from(
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1044,7 +1057,7 @@ impl DbManager {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1052,13 +1065,14 @@ impl DbManager {
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
return pool.execute(statement, max_rows, cancel).await;
return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1070,7 +1084,7 @@ impl DbManager {
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
let r = conn.execute(&pool, statement, max_rows, cancel).await;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
held.take().expect("opened above").discard().await;
+25 -1
View File
@@ -1,7 +1,9 @@
//! Introspection, queries and table edits against a real SQLite file.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
use ide_db::{
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
Ok(())
}
#[tokio::test]
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
}
#[tokio::test]
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
let (_tmp, db) = setup().await;
+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.
+85 -4
View File
@@ -8,8 +8,11 @@ use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
NewAgentTask,
};
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_tools::InstallEvent;
use std::collections::HashSet;
use std::sync::{LazyLock, Mutex};
/// Streams the project's agent events to this window; returns every task.
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
@@ -58,7 +61,39 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
let result =
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
host.end_request(request);
result?
let mut registry = result??;
let installing = installing();
for agent in &mut registry.agents {
agent.installing = installing.contains(&agent.id);
}
Ok(registry)
}
/// Registry installs in progress, by agent id. An install outlives the Settings page
/// that started it, so a second one of the same agent is refused rather than racing it
/// for the same folder.
static INSTALLING: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);
fn installing() -> std::sync::MutexGuard<'static, HashSet<String>> {
INSTALLING.lock().unwrap_or_else(|e| e.into_inner())
}
/// Marks agent `id` as installing until dropped.
struct Installing(String);
impl Installing {
fn begin(id: &str) -> IdeResult<Self> {
if !installing().insert(id.to_owned()) {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{id} is already being installed")));
}
Ok(Self(id.to_owned()))
}
}
impl Drop for Installing {
fn drop(&mut self) {
installing().remove(&self.0);
}
}
/// Installs the registry's current version of agent `id` (also to update it), streaming
@@ -72,11 +107,43 @@ pub async fn registry_install(
on_event: Channel<InstallEvent>,
) -> IdeResult<()> {
let paths = registry_paths(host);
let installing = Installing::begin(&id)?;
let cancel = host.begin_request(request);
// Also a background task of every project window, so it stays visible and can be
// stopped there once the Settings page is closed.
let task = TaskId::next();
let title = format!("Installing {id}");
let sinks: Vec<SharedSink> = host.projects().iter().map(|p| p.sink.clone()).collect();
let report = move |message: Option<String>, fraction: Option<f32>, done: bool| {
for sink in &sinks {
sink.emit(IdeEvent::Task {
id: task,
title: title.clone(),
message: message.clone(),
fraction,
done,
cancellable: !done,
});
}
};
let result = tokio::task::spawn_blocking(move || {
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
let _installing = installing;
let _cancel = ide_core::task::on_cancel(task, {
let cancel = cancel.clone();
move || cancel.cancel()
});
report(None, None, false);
let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
match &e {
InstallEvent::Line { text } => report(Some(text.clone()), None, false),
InstallEvent::Progress { fraction, message } => {
report(Some(message.clone()), fraction.map(|f| f as f32), false)
}
}
let _ = on_event.send(e);
})
});
report(None, Some(1.0), true);
result
})
.await
.map_err(join_err);
@@ -263,6 +330,20 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_agent_installs_once_at_a_time() {
let first = Installing::begin("test-agent").unwrap();
assert!(installing().contains("test-agent"));
let again = Installing::begin("test-agent").err().unwrap();
assert_eq!(again.kind, ErrorKind::AlreadyExists);
let other = Installing::begin("test-other-agent").unwrap();
drop(first);
assert!(!installing().contains("test-agent"));
drop(Installing::begin("test-agent").unwrap());
drop(other);
}
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
+184 -15
View File
@@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
&settings.languages.java.gradle,
);
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
@@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
p.options = options.clone();
}
}
if let Some(g) = &mut model.gradle {
for g in &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
}
@@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
explain(&mut g.sync_error, hint);
for g in &mut model.gradle {
if g.sync_error.is_some() {
let hint =
if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
explain(&mut g.sync_error, hint);
}
}
model
}
@@ -94,9 +96,89 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
fn linked_gradle_paths(
root: &Path,
settings: &ide_core::settings::GradleSettings,
paths: &[String],
link: bool,
) -> IdeResult<Vec<String>> {
let existing = settings
.linked_projects
.clone()
.unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
let mut linked = Vec::new();
for path in existing {
let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
.unwrap_or_else(|_| vec![path]);
for path in normalized {
if !linked.contains(&path) {
linked.push(path);
}
}
}
if link {
let directories: Vec<String> = paths
.iter()
.map(|path| {
let path = root.join(path);
let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
});
if is_script && path.is_file() {
path.parent().unwrap_or(root).to_string_lossy().into_owned()
} else {
path.to_string_lossy().into_owned()
}
})
.collect();
for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
if !linked.contains(&path) {
linked.push(path);
}
}
} else {
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)
}
async fn set_gradle_projects(host: &Host, window: &str, paths: Vec<String>, link: bool) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
}
let linked =
linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
let key = "languages.java.gradle.linkedProjects";
let settings =
project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
project.refresh_index_scope();
project.lsp.reconfigure_soon();
project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok::<BuildModel, IdeError>(load(&project.root, &project.build_cache_dir(), &settings))
})
.await
.map_err(join_err)?
}
/// Links independent builds without changing their Gradle scripts.
pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
set_gradle_projects(host, window, paths, true).await
}
/// Stops importing the selected independent builds; files and saved run configurations stay intact.
pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
set_gradle_projects(host, window, paths, false).await
}
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
/// Returns the refreshed model; a failed build is reported in its `syncError`.
#[allow(clippy::too_many_arguments)]
pub async fn build_sync(
host: &Host,
window: &str,
@@ -104,6 +186,7 @@ pub async fn build_sync(
request: RequestId,
offline: bool,
profiles: Vec<String>,
build_dir: Option<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
@@ -122,6 +205,8 @@ pub async fn build_sync(
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
let completion_token = token.clone();
let original_links = settings.languages.java.gradle.linked_projects.clone();
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -131,7 +216,7 @@ pub async fn build_sync(
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
BuildTool::Gradle => BTreeMap::new(),
};
let options = SyncOptions {
offline,
@@ -140,14 +225,31 @@ pub async fn build_sync(
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => {
if let Some(dir) = &build_dir
&& !current.gradle.iter().any(|g| &g.dir == dir)
{
return Err(IdeError::not_found("Gradle project is no longer linked"));
}
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let options =
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
}
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
}
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
})
@@ -155,6 +257,18 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
if completion_token.is_cancelled() {
return Err(IdeError::cancelled());
}
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
{
// A late sync must not re-import a build that was unlinked while it ran.
return tokio::task::spawn_blocking(move || {
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
})
.await
.map_err(join_err);
}
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
@@ -247,20 +361,26 @@ pub async fn gradle_dependencies(
configuration: String,
offline: bool,
request: RequestId,
build_dir: Option<String>,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let gradle = load(&root, &cache, &settings).gradle;
let model = load(&root, &cache, &settings);
let gradle = model
.gradle
.iter()
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let options = SyncOptions {
offline,
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
env: gradle.env.clone(),
gradle_command: Some(gradle.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -382,6 +502,11 @@ pub async fn build_run(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
d.add(
"gradle_unlink_projects",
|c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
);
d.add("build_sync", |c| async move {
build_sync(
&c.host,
@@ -390,6 +515,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("request")?,
c.arg("offline")?,
c.arg("profiles")?,
c.arg("buildDir")?,
c.sender("onProgress")?,
)
.await
@@ -405,6 +531,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
c.arg("buildDir")?,
)
.await
});
@@ -432,6 +559,48 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod gradle_settings_tests {
use super::*;
#[test]
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for name in ["a", "b"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
}
let mut settings = ide_core::settings::GradleSettings::default();
let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
assert_eq!(linked, ["a", "b"]);
settings.linked_projects = Some(linked);
assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
assert!(root.join("a/settings.gradle").is_file());
assert!(root.join("b/settings.gradle").is_file());
}
#[test]
fn settings_resolve_each_linked_builds_own_wrapper() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let mut settings = Settings::default();
settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join(dir).join(executable), "").unwrap();
}
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.gradle.len(), 2);
for g in model.gradle {
assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -440,13 +609,13 @@ mod gradle_settings_tests {
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
let mut settings = Settings::default();
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
assert_eq!(default.command, wrapper.to_string_lossy());
settings.languages.java.gradle.jvm = "jdk21".into();
settings.languages.java.gradle.user_home = "user-home".into();
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
settings.languages.java.gradle.home = "gradle-install".into();
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
assert_eq!(
model.command,
root.join("gradle-install")
+7 -4
View File
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
let (request, id, statements, max_rows) = (
let (request, id, statements, skip, max_rows) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<Vec<SqlStatement>>("statements")?,
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, max_rows).await
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
}
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Cancel with `cancel_request(request)`.
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
+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,
+3 -1
View File
@@ -196,7 +196,9 @@ pub fn set_settings(
}
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|| "languages.java.maven.linkedProjects".starts_with(key)
|| "languages.java.gradle.linkedProjects".starts_with(key);
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
+4 -4
View File
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RouteSource {
pub path: String,
pub path: ide_core::IpcPath,
pub text: String,
}
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
return;
}
bytes.fetch_add(text.len(), Ordering::Relaxed);
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
found.push(RouteSource { path: path.to_path_buf().into(), text });
});
let mut files = found.into_inner().unwrap_or_default();
files.sort_by(|a, b| a.path.cmp(&b.path));
@@ -116,7 +116,7 @@ mod tests {
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
let files = sources_in(tmp.path(), &[]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
assert!(files[0].text.contains("@GetMapping"));
}
@@ -129,6 +129,6 @@ mod tests {
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
}
}
+20 -2
View File
@@ -23,8 +23,26 @@ pub struct LocatedTools {
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
let project = host.project(window).ok();
let root = project.as_ref().map(|p| p.root.clone());
// The interpreter the project uses is a choice even where detection doesn't look (a
// virtualenv in a subfolder, one picked by path), so it shows by name and version.
let current = match (&project, kind) {
(Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
_ => None,
};
tokio::task::spawn_blocking(move || {
let mut sdks = ide_sdk::detect(kind, root.as_deref());
if let Some(path) = current
&& !sdks.iter().any(|s| *s.path == *path)
&& let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
{
sdks.push(sdk);
}
sdks
})
.await
.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
+2 -2
View File
@@ -492,7 +492,7 @@ pub async fn vcs_add_to_ignore(
window: &str,
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<String>> {
) -> IdeResult<Vec<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
@@ -509,7 +509,7 @@ pub async fn vcs_add_to_ignore(
watcher.reload_ignore_rules();
}
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
result.map(|files| files.into_iter().map(Into::into).collect())
})
.await
}
+73 -2
View File
@@ -2,6 +2,7 @@
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
@@ -324,6 +325,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());
{
@@ -407,7 +420,7 @@ impl HostApi for PluginApi {
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: repo.root().to_string_lossy().into_owned(),
root: to_ipc(repo.root()),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
@@ -418,6 +431,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)) {
@@ -449,7 +468,7 @@ impl HostApi for PluginApi {
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(entry.path().to_string_lossy().into_owned());
out.push(to_ipc(entry.path()));
}
}
out.sort();
@@ -788,6 +807,19 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
let root = tmp.path().canonicalize().unwrap();
std::fs::create_dir(root.join("src")).unwrap();
let file = root.join("src/Example.java");
std::fs::write(&file, "class Example {}\n").unwrap();
let api = api(&root, Arc::default());
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
@@ -812,3 +844,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");
}
}
@@ -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
+116 -11
View File
@@ -230,6 +230,11 @@ impl Host {
if cfg.name == "jdtls" {
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
ide_sdk::apply::configure_gradle_import(
cfg,
effective.languages.java.gradle.linked_projects.as_deref(),
build_files.as_deref(),
);
}
}));
}
@@ -876,8 +881,14 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
linked_roots(&self.root, &self.settings.effective(), &detected)
let settings = self.settings.effective();
let detected = with_gradle_roots(
&self.root,
&self.build_cache_dir(),
&settings.languages.java.gradle,
&self.detected_roots.read(),
);
linked_roots(&self.root, &settings, &detected)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -1078,7 +1089,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
}
let mut out: Vec<PathBuf> = Vec::new();
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1182,31 +1193,93 @@ pub(crate) fn jdtls_maven_import(
settings: &ide_core::settings::Settings,
) -> (Vec<PathBuf>, Option<Vec<String>>) {
let maven = &settings.languages.java.maven;
if maven.unlinked_projects.is_empty() {
let gradle = &settings.languages.java.gradle;
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
return (Vec::new(), None);
}
let model = ide_build::maven::load(root);
let model = ide_build::maven::load_linked(root, maven);
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
let excluded = unlinked.poms().map(PathBuf::from).collect();
let build_files = unlinked.import_poms(&model).map(|poms| {
let gradle = ide_build::gradle::load(root).and_then(|g| {
let poms = unlinked.import_poms(&model).or_else(|| {
gradle.linked_projects.as_ref().map(|_| {
model
.projects
.iter()
.filter(|p| !p.aggregated && !unlinked.contains(&p.pom))
.map(|p| p.pom.clone())
.collect()
})
});
let build_files = poms.map(|poms| {
let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| {
let dir = root.join(&g.dir);
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
names.map(|n| dir.join(n)).find(|f| f.is_file())
});
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
let eclipse = if settings.languages.java.gradle.linked_projects.is_some() {
independent_eclipse_projects(root)
} else {
Vec::new()
};
poms.iter()
.map(|pom| root.join(pom))
.chain(gradle)
.chain(eclipse)
.map(|f| ide_lsp::path_to_uri_string(&f))
.collect()
});
(excluded, build_files)
}
/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata.
fn independent_eclipse_projects(root: &Path) -> Vec<PathBuf> {
let managed = |dir: &Path| {
std::iter::once("pom.xml")
.chain(ide_build::gradle::SETTINGS_FILES.iter().copied())
.chain(ide_build::gradle::BUILD_FILES.iter().copied())
.any(|name| dir.join(name).is_file())
};
if managed(root) {
return Vec::new();
}
let walker = ignore::WalkBuilder::new(root)
.standard_filters(false)
.filter_entry(move |entry| {
if !entry.file_type().is_some_and(|kind| kind.is_dir()) {
return false;
}
if entry.depth() > 0
&& (entry.file_name().to_string_lossy().starts_with('.')
|| matches!(
entry.file_name().to_str(),
Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor")
)
|| entry.path().parent().is_some_and(|parent| parent.join(".project").is_file()))
{
return false;
}
!managed(entry.path())
})
.build();
let mut projects: Vec<_> =
walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect();
projects.sort();
projects
}
/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what
/// static detection guesses. Folders outside the project keep their absolute path, which
/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`).
fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec<SourceFolder> {
fn with_gradle_roots(
root: &Path,
build_cache: &Path,
settings: &ide_core::settings::GradleSettings,
detected: &[SourceFolder],
) -> Vec<SourceFolder> {
use ide_build::gradle::ReportedRoot;
use ide_workspace::roots::RootKind;
let mut out: Vec<SourceFolder> = Vec::new();
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
if !dir.is_dir() {
continue;
}
@@ -1376,6 +1449,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
#[cfg(test)]
mod maven_linkage_tests {
#[test]
fn explicit_gradle_links_preserve_independent_eclipse_projects() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for dir in
["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"]
{
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join(".project"), "<projectDescription/>").unwrap();
}
for dir in ["linked", "unlinked"] {
std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project/>").unwrap();
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]);
let (_, files) = super::jdtls_maven_import(root, &settings);
let files = files.unwrap();
let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path));
assert!(files.contains(&uri("linked/settings.gradle")));
assert!(files.contains(&uri("maven/pom.xml")));
assert!(files.contains(&uri("eclipse/.project")));
assert!(files.contains(&uri("nested/eclipse/.project")));
assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated")));
assert!(!files.contains(&uri("linked/.project")));
assert!(!files.contains(&uri("maven/.project")));
settings.languages.java.gradle.linked_projects = Some(vec![]);
let (_, files) = super::jdtls_maven_import(root, &settings);
assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]);
}
#[test]
fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
@@ -1473,7 +1577,8 @@ mod gradle_roots_tests {
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
std::fs::write(root.join("build.gradle"), "").unwrap();
let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
assert_eq!(
+4
View File
@@ -217,6 +217,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "lsp_resend_diagnostics"
| "recent_projects"
| "remove_recent_project"
| "recent_project_groups"
| "set_recent_project_groups"
| "open_project"
| "close_project"
| "project_current"
@@ -249,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"
+49
View File
@@ -0,0 +1,49 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn links_are_validated_persisted_and_unlinked_without_removing_files() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("workspace");
for dir in [root.join("a"), root.join("b"), base.join("external")] {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap();
}
let config = base.join("config");
let host = Host::new(HostConfig {
config_dir: config.clone(),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_project("main", &root).unwrap();
assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err());
host.set_project_trust(&root, true, false).await.unwrap();
let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap();
assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["a", "b"]);
let saved =
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.gradle.linked_projects;
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
assert!(ops::build::gradle_link_projects(&host, "main", vec!["missing".into()]).await.is_err());
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
let outside = base.join("external").to_string_lossy().into_owned();
let model = ops::build::gradle_link_projects(&host, "main", vec![outside.clone()]).await.unwrap();
assert_eq!(model.gradle.len(), 3);
let model = ops::build::gradle_unlink_projects(&host, "main", vec!["a".into(), "b".into(), outside]).await.unwrap();
assert!(model.gradle.is_empty());
assert_eq!(saved(), Some(vec![]));
for dir in [root.join("a"), root.join("b"), base.join("external")] {
assert!(dir.join("settings.gradle").is_file());
}
let model = ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.unwrap();
assert_eq!(model.gradle.len(), 1);
assert_eq!(model.gradle[0].dir, "b");
host.shutdown().await;
}
+62 -5
View File
@@ -6,6 +6,7 @@
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
use ide_core::error::ErrorKind;
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
@@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture {
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let project = project.canonicalize().unwrap();
let project = dunce::canonicalize(project).unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
@@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() {
let inv = Invocation { client: None, workspace_root: None, editor: None };
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
@@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() {
let java = f.project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = |f: &Fixture| {
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
@@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
@@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
@@ -2077,6 +2078,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());
+8 -4
View File
@@ -223,7 +223,8 @@ async fn kotlin_gradle() {
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let gradle = model.gradle.expect("a Gradle model");
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
let gradle = &model.gradle[0];
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
}
@@ -231,8 +232,9 @@ async fn kotlin_gradle() {
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
let p = open(fixture).await;
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
let model =
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress)
.await
.unwrap();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
@@ -253,7 +255,9 @@ async fn maven_sync() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await;
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
let gradle = &model.gradle[0];
assert_eq!(gradle.sync_error, None);
assert!(gradle.synced_at.is_some());
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
+1
View File
@@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
ide_core::RequestId(472),
false,
vec![],
None,
ops::Channel::new(|_| {}),
)
};
+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"
+52 -15
View File
@@ -13,6 +13,7 @@ use crate::types::{
};
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
@@ -32,7 +33,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 +46,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 +75,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 +212,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 +232,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 +555,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)?;
@@ -915,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location {
}
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
wit_prov::Document {
path: d.path.to_string_lossy().into_owned(),
language: d.language.clone(),
version: d.version,
text: d.text.clone(),
}
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
@@ -992,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: path.to_string_lossy().into_owned(),
path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
@@ -1111,6 +1127,27 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
for (path, expected) in [
(r"C:\project\Example.java", "C:/project/Example.java"),
(r"\\?\C:\project\Example.java", "C:/project/Example.java"),
(r"\\server\share\Example.java", "//server/share/Example.java"),
] {
let doc = ProviderDocument { path: path.into(), ..Default::default() };
assert_eq!(doc_to_wit(&doc).path, expected);
let action = CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
data: None,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
}
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
+30 -2
View File
@@ -5,6 +5,7 @@
use crate::runtime::{StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -242,6 +243,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 {
@@ -292,7 +311,8 @@ impl workspace::HostProject for StoreData {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
// Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
}
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
@@ -328,7 +348,7 @@ impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
}
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
@@ -438,6 +458,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);
}
+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"]);
+49
View File
@@ -429,6 +429,36 @@ pub fn configure_maven_import(
}
}
/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state.
pub fn configure_gradle_import(
cfg: &mut LanguageServerConfig,
linked: Option<&[String]>,
build_files: Option<&[String]>,
) {
let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return };
merge_init_options(
cfg,
json!({
"settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}},
"projectConfigurations": build_files.unwrap_or_default()
}),
);
if let Some(settings) = &mut cfg.settings {
merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}));
}
if let Some(index) = cfg.args.iter().position(|arg| arg == "-data")
&& let Some(workspace) = cfg.args.get_mut(index + 1)
{
let mut hash = 0xcbf29ce484222325u64;
for dir in linked {
for byte in dir.bytes().chain([0]) {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
}
}
*workspace = format!("{workspace}-gradle-{hash:016x}");
}
}
pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
let mut current =
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({}));
@@ -1218,6 +1248,25 @@ mod java_compiler_tests {
mod maven_linkage_tests {
use super::*;
#[test]
fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
cfg.args = vec!["-data".into(), "/cache/jdtls".into()];
let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()];
configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files));
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
assert_eq!(options["projectConfigurations"], json!(files));
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true)));
assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-"));
let linked_cache = cfg.args[1].clone();
cfg.args[1] = "/cache/jdtls".into();
configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..]));
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
assert_eq!(options["projectConfigurations"], json!(&files[1..]));
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false)));
assert_ne!(linked_cache, cfg.args[1]);
}
#[test]
fn import_excludes_unlinked_modules_and_changes_persistent_workspace() {
let tmp = tempfile::tempdir().unwrap();
+9 -2
View File
@@ -184,6 +184,12 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
par_map(&candidates, |(path, source)| python_sdk(path, source, root))
}
/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named
/// like the detected ones.
pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option<Sdk> {
python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root)
}
pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option<Sdk> {
let version = query(path, &["--version"], VERSION_TIMEOUT)
.and_then(|v| v.strip_prefix("Python ").map(str::to_owned))
@@ -193,10 +199,11 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
let label = match (&venv, source) {
(Some(home), _) => {
let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
// `.venv` inside a project reads better as the project name, like PyCharm.
// `.venv` inside a project reads better as the folder that holds it (the project,
// or a member such as `backend`), like PyCharm.
let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) {
(true, Some(owner), Some(root)) if home.parent() == Some(root) => format!("Python {short} ({owner})"),
(true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"),
_ => format!("Python {short} ({dir})"),
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
pub use discover::{detect, jdk_sdk};
pub use discover::{detect, jdk_sdk, python_interpreter_sdk};
/// What [`detect`] looks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+17
View File
@@ -76,6 +76,23 @@ fn project_venv_is_the_default_interpreter() {
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
}
#[test]
fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("shop");
let member = fake_venv(&root.join("backend"), ".venv");
let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
assert_eq!(
(sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
("Python 3.12 (backend)", Some("3.12.1"), "venv")
);
let own = fake_venv(&root, ".venv");
assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
// Outside the project the folder name says which one it is.
let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
}
#[test]
fn pyright_gets_python_path_and_venv_env() {
let tmp = tempfile::tempdir().unwrap();
+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()));
}
}
+22 -1
View File
@@ -404,6 +404,9 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
match (prev, next) {
(None, n) => n,
(Some(Vcs), _) | (_, Vcs) => Vcs,
// FSEvents reports a deleted file's history as create, remove, then modify: a
// modify can't bring back a path that went away, only a create can.
(Some(Transient), Modified) => Transient,
(Some(Created | Transient), Modified | Created) => Created,
(Some(Created | Transient), Removed) => Transient,
(Some(Removed), Created | Modified) => Modified,
@@ -630,7 +633,7 @@ impl EventFilter {
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, DataChange, RemoveKind};
use notify::event::{CreateKind, DataChange, MetadataKind, RemoveKind};
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event { kind, paths, attrs: Default::default() }
@@ -788,6 +791,24 @@ mod tests {
assert_eq!(batch.ignored, vec![root.join("debug.log")], "ignored files are reported apart");
}
/// What FSEvents delivers when a file is deleted: its coalesced history flags as
/// create, remove, then modify events, all for a path that no longer exists.
#[test]
fn fsevents_deletion_with_history_flags_is_a_removal() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let filter = EventFilter::new(root.clone(), &[]);
let gone = root.join("src/Fresh.java");
let batch = filter.batch(&[
ev(EventKind::Create(CreateKind::File), vec![gone.clone()]),
ev(EventKind::Remove(RemoveKind::File), vec![gone.clone()]),
ev(EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)), vec![gone.clone()]),
ev(EventKind::Modify(ModifyKind::Data(DataChange::Content)), vec![gone.clone()]),
]);
assert_eq!(batch.removed, vec![gone]);
assert!(batch.created.is_empty() && batch.modified.is_empty());
}
#[test]
fn atomic_save_temp_files_are_not_reported() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -25,7 +25,7 @@ pub use editorconfig::{EditorConfig, EditorConfigProps};
pub use file_defaults::FileDefaults;
pub use file_moves::{FileMove, FileMoves, UndoOutcome};
pub use history::{DirectoryHistory, HistoryEntry, HistoryKind, LocalHistory, Retention};
pub use recent::{RecentProject, RecentProjects};
pub use recent::{RecentProject, RecentProjectGroup, RecentProjects};
pub use roots::{ProjectRoots, RootKind, SourceFolder};
pub use settings::{SettingsScope, SettingsStore};
+140 -22
View File
@@ -16,10 +16,78 @@ pub struct RecentProject {
pub const MAX_RECENT_PROJECTS: usize = 30;
/// Persisted at `<app-config>/recent.json`, newest first.
/// A named group of recent projects on the welcome screen (New Project Group…, Move to Group).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RecentProjectGroup {
pub name: String,
/// Its projects, in the order they were added.
pub paths: Vec<ide_core::IpcPath>,
#[serde(default = "expanded")]
pub expanded: bool,
}
fn expanded() -> bool {
true
}
/// Persisted at `<app-config>/recent.json`, newest first; groups at `<app-config>/recent-groups.json`.
pub struct RecentProjects {
file: PathBuf,
items: Mutex<Vec<RecentProject>>,
groups_file: PathBuf,
groups: Mutex<Vec<RecentProjectGroup>>,
}
fn read_json<T: serde::de::DeserializeOwned>(file: &Path) -> Option<T> {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| tracing::warn!("{} is invalid, ignoring: {e}", file.display()))
.ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!("cannot read {}: {e}", file.display());
None
}
}
}
fn write_json<T: Serialize + ?Sized>(file: &Path, value: &T) -> IdeResult<()> {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_vec_pretty(value).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(file, &json)?;
Ok(())
}
/// Each path in one group at most (the first keeps it), group names trimmed and unique.
fn normalize(groups: Vec<RecentProjectGroup>) -> Vec<RecentProjectGroup> {
let mut paths = std::collections::HashSet::new();
let mut names = std::collections::HashSet::new();
groups
.into_iter()
.filter_map(|mut g| {
g.name = g.name.trim().to_owned();
if g.name.is_empty() || !names.insert(g.name.clone()) {
return None;
}
g.paths.retain(|p| paths.insert(p.clone()));
Some(g)
})
.collect()
}
/// Keeps the newest [`MAX_RECENT_PROJECTS`] projects outside groups; grouped ones stay.
fn cap(items: &mut Vec<RecentProject>, groups: &[RecentProjectGroup]) {
let mut ungrouped = 0;
items.retain(|p| {
if groups.iter().any(|g| g.paths.contains(&p.path)) {
return true;
}
ungrouped += 1;
ungrouped <= MAX_RECENT_PROJECTS
});
}
fn now_ms() -> f64 {
@@ -29,28 +97,40 @@ fn now_ms() -> f64 {
impl RecentProjects {
pub fn load(config_dir: &Path) -> Self {
let file = config_dir.join("recent.json");
let mut items: Vec<RecentProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("{} is invalid, ignoring: {e}", file.display());
Vec::new()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
tracing::warn!("cannot read {}: {e}", file.display());
Vec::new()
}
};
let groups_file = config_dir.join("recent-groups.json");
let mut items: Vec<RecentProject> = read_json(&file).unwrap_or_default();
let mut groups = normalize(read_json(&groups_file).unwrap_or_default());
items.sort_by(|a, b| b.opened_at_ms.total_cmp(&a.opened_at_ms));
let mut seen = std::collections::HashSet::new();
items.retain(|p| seen.insert(p.path.clone()));
items.truncate(MAX_RECENT_PROJECTS);
Self { file, items: Mutex::new(items) }
cap(&mut items, &groups);
// A group lists only projects that are still recent.
for g in &mut groups {
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
}
Self { file, items: Mutex::new(items), groups_file, groups: Mutex::new(groups) }
}
pub fn list(&self) -> Vec<RecentProject> {
self.items.lock().clone()
}
pub fn groups(&self) -> Vec<RecentProjectGroup> {
self.groups.lock().clone()
}
/// Replaces the groups (paths that are not recent projects are dropped).
pub fn set_groups(&self, groups: Vec<RecentProjectGroup>) -> IdeResult<Vec<RecentProjectGroup>> {
let items = self.items.lock();
let mut groups = normalize(groups);
for g in &mut groups {
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
}
write_json(&self.groups_file, &groups)?;
*self.groups.lock() = groups.clone();
Ok(groups)
}
/// Moves (or adds) `path` to the top of the list.
pub fn touch(&self, path: &Path, branch: Option<String>) -> IdeResult<()> {
let mut items = self.items.lock();
@@ -58,7 +138,7 @@ impl RecentProjects {
let name =
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
items.insert(0, RecentProject { path: path.into(), name, opened_at_ms: now_ms(), branch });
items.truncate(MAX_RECENT_PROJECTS);
cap(&mut items, &self.groups.lock());
self.persist(&items)
}
@@ -83,7 +163,15 @@ impl RecentProjects {
if items.len() == before {
return Ok(());
}
self.persist(&items)
self.persist(&items)?;
let mut groups = self.groups.lock();
if groups.iter().any(|g| g.paths.iter().any(|p| p == path)) {
for g in groups.iter_mut() {
g.paths.retain(|p| p != path);
}
write_json(&self.groups_file, &*groups)?;
}
Ok(())
}
pub fn paths(&self) -> Vec<PathBuf> {
@@ -91,12 +179,7 @@ impl RecentProjects {
}
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
if let Some(dir) = self.file.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_vec_pretty(items).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(&self.file, &json)?;
Ok(())
write_json(&self.file, items)
}
}
@@ -131,6 +214,41 @@ mod tests {
assert!(RecentProjects::load(&cfg).list().is_empty());
}
#[test]
fn groups_keep_their_projects_and_survive_a_reload() {
let tmp = tempfile::tempdir().unwrap();
let recent = RecentProjects::load(tmp.path());
for i in 0..3 {
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
}
let group = |name: &str, paths: &[&str]| RecentProjectGroup {
name: name.into(),
paths: paths.iter().map(|p| PathBuf::from(p).into()).collect(),
expanded: true,
};
let saved = recent
.set_groups(vec![
group(" Work ", &["/p/proj0", "/p/gone"]),
group("Work", &["/p/proj1"]),
group("Oss", &["/p/proj0", "/p/proj2"]),
group(" ", &[]),
])
.unwrap();
// Trimmed, unique names; each project in one group; only recent projects.
assert_eq!(saved, vec![group("Work", &["/p/proj0"]), group("Oss", &["/p/proj2"])]);
assert_eq!(RecentProjects::load(tmp.path()).groups(), saved);
// A grouped project outlives the cap; removing it from recent projects ungroups it.
for i in 3..40 {
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
}
let paths = recent.paths();
assert_eq!(paths.len(), MAX_RECENT_PROJECTS + 2);
assert!(paths.contains(&PathBuf::from("/p/proj0")) && paths.contains(&PathBuf::from("/p/proj2")));
recent.remove(Path::new("/p/proj2")).unwrap();
assert_eq!(RecentProjects::load(tmp.path()).groups(), vec![group("Work", &["/p/proj0"]), group("Oss", &[])]);
}
#[test]
fn rename_shows_a_custom_name_until_reset() {
let tmp = tempfile::tempdir().unwrap();
+7 -2
View File
@@ -192,7 +192,9 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` |
| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` |
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` |
@@ -209,6 +211,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` |
| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` |
@@ -226,7 +229,9 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file.
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file. An
attribute written as `<value />` has no colours of its own and shows as plain
text (`TEXT`'s foreground), as in IntelliJ, not with the parent scheme's colour.
### From VS Code (by hand)
+9 -1
View File
@@ -22,7 +22,15 @@ Paths in an existing manifest may be relative to the workspace directory. New an
Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration.
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders.
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action.
## Linked Gradle builds in a project
In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them.
Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder.
Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches.
## Window group
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { expect, test } from "./fixtures";
const xml = readFileSync(
process.env.OPENIDEA_ICLS_FIXTURE ?? "src/platform/__fixtures__/colorSchemes/monokai-php.icls",
"utf8",
);
test("imported PHP colours stay separate from Java and preserve regular text on a dark background", async ({
workbench: page,
}, info) => {
await page.evaluate(async (xml) => {
const iclsPath = "/src/platform/icls.ts";
const projectPath = "/src/platform/project.ts";
const backendPath = "/src/editor/core/backend.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { parseIcls } = await import(iclsPath);
const { useProject } = await import(projectPath);
const { docBackend } = await import(backendPath);
const { editorService } = await import(contributionsPath);
const previous = useProject.getState().settings ?? {};
useProject.setState({
settings: {
...previous,
editor: {
...previous.editor,
colorScheme: "custom-php",
customSchemes: { "custom-php": parseIcls(xml).scheme },
semanticHighlighting: false,
},
},
});
const backend = docBackend();
const open = backend.open.bind(backend);
backend.open = async (...args: Parameters<typeof open>) => {
const result = await open(...args);
return args[0].endsWith(".php") ? { ...result, language: "php", uiMode: "PHP" } : result;
};
backend.files.set(
"/dev/acme-api/palette.php",
"<?php\n/** Plain PHP documentation */\nclass Palette extends BasePalette {\n var $name = 'Palette';\n function example($value) {\n return $this->name == 10;\n }\n}\n",
);
await editorService().openFile("/dev/acme-api/palette.php");
}, xml);
const php = page.locator('.cm-editor[data-language="PHP"]');
await expect(php).toBeVisible();
await expect(php).toHaveCSS("background-color", "rgb(0, 0, 0)");
const token = (text: string) =>
php
.locator(".cm-content span")
.filter({ hasText: text })
.filter({ hasNot: php.locator("span span") });
await expect(token("$name").first()).toHaveCSS("color", "rgb(72, 216, 238)");
await expect(token("'Palette'")).toHaveCSS("color", "rgb(255, 231, 146)");
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
await expect(token("class").first()).toHaveCSS("color", "rgb(249, 38, 114)");
await expect(token("Plain PHP documentation")).toHaveCSS("font-style", "normal");
await expect(token("BasePalette")).toHaveCSS("color", "rgb(174, 129, 255)");
await expect(token(";").first()).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(token("example")).toHaveCSS("color", "rgb(141, 225, 45)");
await expect(token("$name").first()).toHaveCSS("font-weight", "700");
await page.evaluate(async () => {
const backendPath = "/src/editor/core/backend.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { docBackend } = await import(backendPath);
const { editorService } = await import(contributionsPath);
docBackend().files.set("/dev/acme-api/Palette.java", 'class Palette { String name = "Palette"; }');
await editorService().openFile("/dev/acme-api/Palette.java");
});
const java = page.locator('.cm-editor[data-language="Java"]');
await expect(java).toBeVisible();
await expect(java.locator(".cm-content span").filter({ hasText: '"Palette"' })).toHaveCSS("font-weight", "700");
await page.getByRole("tab", { name: "palette.php", exact: true }).click();
await expect(php).toBeVisible();
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
await php.screenshot({ path: info.outputPath("php-import.png"), animations: "disabled" });
});
+120
View File
@@ -0,0 +1,120 @@
/** Linked Gradle builds use isolated native stubs; no real builds or files are touched. */
import type { GradleModel, RunConfig } from "../src/platform/ipc";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const build = (dir: string): GradleModel => ({
dir,
command: `/dev/acme-api/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
javaVersion: 21,
syncedAt: 1,
stale: false,
syncError: null,
projects: [
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
{
path: ":app",
name: "app",
dir: `${dir}/app`,
buildFile: `${dir}/app/build.gradle`,
tasks: [{ name: "assemble", group: "build", description: "Assembles this project" }],
},
],
});
test("links independent Gradle roots, runs identical task paths in their own builds and unlinks without deletion", async ({
workbench: page,
}, info) => {
await stubDesktop(page);
await page.evaluate(
async ({ first, second }) => {
let linked = [first];
let runId = 0;
const model = () => ({ maven: null, gradle: linked });
const h = window.__ipc.handlers;
h.build_model = model;
h.build_sync = model;
h.run_configs = () => [];
h.run_apply_settings = (args) => args.configs;
h.gradle_link_projects = () => {
if (!linked.some((g) => g.dir === second.dir)) linked = [...linked, second];
return model();
};
h.gradle_unlink_projects = (args) => {
linked = linked.filter((g) => !(args.paths as string[]).includes(g.dir));
return model();
};
h["plugin:dialog|open"] = () => ["/dev/acme-api/second/settings.gradle"];
h.run_start = (args) => {
const config = args.config as RunConfig;
const id = ++runId;
setTimeout(() => window.__ipc.emit({ type: "runExited", run: id, code: 0 }), 50);
return { id, configId: config.id, name: config.name, pid: 4242 + id, running: true, pty: false };
};
const storeModule = "/src/features/build-tools/store.ts";
const { loadModel } = await import(/* @vite-ignore */ storeModule);
await loadModel();
const contributionsModule = "/src/workbench/contributions.ts";
const { toolWindowService } = await import(/* @vite-ignore */ contributionsModule);
toolWindowService().show("gradle", true);
},
{ first: build("first"), second: build("second") },
);
const tree = page.getByRole("tree", { name: "Gradle projects" });
await expect(tree.getByRole("treeitem", { name: "first", exact: true })).toBeVisible();
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath("gradle-one-root.png"), animations: "disabled" });
await page.getByRole("button", { name: "Link Gradle Projects", exact: true }).click();
await expect
.poll(() => ipcCalls(page, "gradle_link_projects"))
.toEqual([{ paths: ["/dev/acme-api/second/settings.gradle"] }]);
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toBeVisible();
// Reloading the model reads the persisted linkage again.
await page.evaluate(async () => {
const storeModule = "/src/features/build-tools/store.ts";
const { loadModel } = await import(/* @vite-ignore */ storeModule);
await loadModel();
const actionModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionModule);
await executeAction("gradle.expandAll");
await executeAction("gradle.toggleOffline");
});
const tasks = tree.getByRole("treeitem", { name: /^assemble\b/ });
await expect(tasks).toHaveCount(2);
await page.screenshot({ path: info.outputPath("gradle-two-roots.png"), animations: "disabled" });
await tasks.nth(0).dblclick();
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1);
await tasks.nth(1).dblclick();
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(2);
const runs = (await ipcCalls(page, "run_start")).map((args) => args.config as RunConfig);
expect(runs[0]!.id).not.toBe(runs[1]!.id);
for (const [index, dir] of ["first", "second"].entries()) {
expect(runs[index]).toMatchObject({
command: `/dev/acme-api/${dir}/gradlew`,
cwd: dir,
args: [":app:assemble", "--offline"],
env: { JAVA_HOME: `/${dir}/jdk` },
});
}
for (const dir of ["second", "first"]) {
await tree.getByRole("treeitem", { name: dir, exact: true }).click({ button: "right" });
await page.getByRole("menuitem", { name: "Unlink Gradle Project", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Unlink Gradle project?" });
await expect(dialog).toContainText("Files will remain");
await dialog.getByRole("button", { name: "Unlink", exact: true }).click();
await expect(tree.getByRole("treeitem", { name: dir, exact: true })).toHaveCount(0);
}
await expect
.poll(() => ipcCalls(page, "gradle_unlink_projects"))
.toEqual([{ paths: ["second"] }, { paths: ["first"] }]);
await expect(page.getByText("No Gradle projects", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Link Gradle Projects", exact: true })).toBeVisible();
expect(
await page.evaluate(() => window.__ipc.calls.filter((c) => /^fs_(?:delete|remove|trash)/.test(c.cmd))),
).toEqual([]);
await page.screenshot({ path: info.outputPath("gradle-unlinked.png"), animations: "disabled" });
});
+28
View File
@@ -0,0 +1,28 @@
import { expect, openFromTree, test } from "./fixtures";
// Headless Chromium hides scrollbars by default, so nothing native sits at the editor's right
// edge and the press reaches the overview lane. The tests in `overviewLane.spec.ts` keep a
// scrollbar there instead.
test("pressing the overview lane scrolls instead of starting a text selection", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val line${i} = ${i}`).join("\n"));
await page.keyboard.press("Escape");
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((s) => (s.scrollTop = 0));
const box = (await scroller.boundingBox())!;
// The lane replaces the native scrollbar: a press on it must stay a scrollbar press.
const x = box.x + box.width - 5;
const y = box.y + box.height - 40;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y - 120, { steps: 8 });
await page.mouse.up();
const empty = await editor.locator(".cm-content").evaluate((el) => {
const view = (el as unknown as { cmTile: { view: { state: { selection: { main: { empty: boolean } } } } } }).cmTile
.view;
return view.state.selection.main.empty;
});
expect(empty).toBe(true);
});
+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": {
+10 -5
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. |
@@ -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
+1 -1
View File
@@ -103,7 +103,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
+1 -1
View File
@@ -1,6 +1,6 @@
id = "openidea.checkstyle"
name = "Checkstyle"
version = "0.1.0"
version = "0.1.1"
description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
+62 -6
View File
@@ -110,6 +110,26 @@ fn project_of(path: &str) -> Option<Project> {
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
format!("//{rest}")
} else {
path.strip_prefix("//?/").unwrap_or(&path).to_owned()
}
}
fn report_matches_file(reported: &str, path: &str) -> bool {
// WASI plugins run on every host, so interpret the host's path rather than cfg(windows).
let drive = path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic();
if drive || path.starts_with("//") || path.starts_with(r"\\") {
let (reported, path) = (windows_path(reported), windows_path(path));
reported == path || path.ends_with(&reported)
} else {
reported == path || path.ends_with(reported)
}
}
/// The configuration for files of `project`.
fn config_for(project: &Project) -> String {
PROJECT_CONFIGS
@@ -138,11 +158,8 @@ impl Checkstyle {
return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report"))));
}
let report = parse_report(&out.stdout);
let problems = report
.into_iter()
.find(|(f, _)| f == path || path.ends_with(f.as_str()))
.map(|(_, p)| p)
.unwrap_or_default();
let problems =
report.into_iter().find(|(f, _)| report_matches_file(f, path)).map(|(_, p)| p).unwrap_or_default();
let text = project.read_file(path).unwrap_or_default();
let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect();
host::publish_diagnostics(path, &diagnostics)?;
@@ -233,7 +250,7 @@ impl Plugin for Checkstyle {
register_plugin!(Checkstyle, providers);
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use openidea_plugin::ProcessOutput;
@@ -288,6 +305,45 @@ mod tests {
assert_eq!(actions[1].edits[0].1[0].text, " void f() {}");
}
#[test]
fn unix_report_paths_preserve_backslashes() {
assert!(report_matches_file("/project/A\\B.java", "/project/A\\B.java"));
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
("C:/project", r"C:\project\src\A.java"),
("C:/project", r"\\?\C:\project\src\A.java"),
("C:/project", r"src\A.java"),
("//server/share/project", r"\\server\share\project\src\A.java"),
("//server/share/project", r"\\?\UNC\server\share\project\src\A.java"),
] {
let host =
MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
host.set_project_root(Some(root));
host.add_file("src/A.java", "class A {\n\tint x;\n void f() {} \n}");
let xml = REPORT.replace("/project/src/A.java", reported);
host.on_process("checkstyle", move |_, _| {
Ok(ProcessOutput { status: Some(1), stdout: xml.clone(), stderr: String::new() })
});
let path = format!("{root}/src/A.java");
let mut plugin = Checkstyle::default();
plugin.handle_events(&[Event::FileSaved(path.clone())], &host.invocation());
assert_eq!(host.diagnostics()[&path].len(), 2, "{reported}");
let doc = Document {
path: path.clone(),
language: "java".into(),
version: 1,
text: "class A {\n\tint x;\n void f() {} \n}".into(),
};
let actions = plugin.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0)));
assert_eq!(actions.len(), 2, "{reported}");
assert_eq!(actions[0].edits[0].0, path);
}
}
#[test]
fn the_projects_configuration_wins() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
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.
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff. Show more