Compare commits

...
Author SHA1 Message Date
l0ng-ai d3950f8a66 fix(tools): keep searching past broken Python aliases 2026-11-05 10:59:36 +08:00
l0ng-ai ad1fdf9e0e 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.
2026-11-05 10:30:03 +08:00
Lee Dong Geon 54502e0a17 fix(run): run single Java files whose main class is not the first top-level type (#1417)
A Java file outside a Maven / Gradle build ran as `java File.java` (JEP 330), which launches the file's first top-level type on JDK 21 and earlier. A file whose main is in a later class failed with "can't find main(String[]) method in class: Parent", and the gutter Run of a second or nested class's main launched the first class. Such files now compile with the JDK's own `javac -g -d <class dir>` and run `java -cp <class dir> <main class>`; a main in the first class, and compact source files, still use the source launcher. Debug passes the JDWP agent through JDK_JAVA_OPTIONS, which only the java launcher reads.

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 10:28:21 +08:00
l0ng-ai e129840651 build: name the quick-xml version ide-host uses in Cargo.lock (#1425)
#1176 brought in quick-xml 0.41 next to ide-host's 0.42, so ide-host's dependency in Cargo.lock must name its version; without it cargo rewrites the lock on every build and --locked builds fail.
2026-11-05 10:24:43 +08:00
l0ng-ai 77f86616c0 fix(host): don't block the event loop while a closing project shuts down (#1419)
Window destruction and the close_project command detach the project at once and let its language servers, kernels and debug sessions shut down in the background; opening a project waits for pending shutdowns and exit waits for them with a bound.

Closes #1414
2026-11-05 10:06:18 +08:00
l0ng-ai 89ffde304b fix(plugins): read the marketplace index on first open (#1378)
On a fresh install, Settings › Plugins › Available showed no marketplace plugins until the user refreshed by hand: opening the page asked for the listing without a refresh, which only returned the cached index, and a first run has none. The listing now reads the index when none has been read yet; later opens keep using the index already read, and an explicit refresh reads it again.
2026-11-05 10:04:42 +08:00
Piettro 1248c3231e feat(linux): open and save dialogs through xdg-desktop-portal (#1176)
On Linux, open and save dialogs were always the in-process GTK3 file chooser, even on KDE Plasma, because tauri-plugin-dialog defaults to its gtk3 feature. It now uses the xdg-portal feature, so dialogs go through xdg-desktop-portal and show the desktop's own chooser; without a portal, rfd falls back to zenity. Only Linux and the BSDs change; macOS and Windows dialogs are the same. Message, ask and confirm boxes are drawn by the app and don't use the plugin.

Co-authored-by: pcastro93 <[email protected]>
2026-11-05 10:04:26 +08:00
qiaojianlong ff9f821621 fix(tools): reuse the gh login for GitHub API downloads (#967)
Tool downloads from GitHub releases called api.github.com anonymously unless GITHUB_TOKEN or GH_TOKEN was set, so they hit the 60-requests-an-hour limit and failed with 403. When neither variable is set, the request now uses the active github.com account from `gh auth token` (the same login the GitHub features use), asked per request with a 5 s, cancellable lookup. The token goes to curl over stdin (`--header @-`), only for https://api.github.com, with `-q` so a user curlrc can't log or forward it, HTTPS-only redirects, and redacted from error messages. Without gh or a login, downloads stay anonymous.

Co-authored-by: qjlqyf <[email protected]>
2026-11-05 09:56:38 +08:00
huanglininnandmoyining 5279443ac5 fix(debugger): send Windows netcoredbg source paths with backslashes (#1409)
Co-authored-by: moyining <[email protected]>
2026-11-05 09:55:02 +08:00
Lee Dong Geon e648c0c46f ci: build with line tables only and move actions to Node 24 (#1194)
CI Rust jobs build with line tables only (CARGO_PROFILE_DEV_DEBUG), which cut the Rust jobs from about 12 to 7.7 minutes on Ubuntu and 18 to 12.6 minutes on Windows and shrinks the cache. The workflows move to the actions releases that run on Node 24: checkout, setup-node and pnpm/action-setup v5, upload-artifact v6, github-script v8 (ci, pr-check and release).

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 09:53:14 +08:00
Fabio Nunes 791c957284 chore(build): keep only line tables in dev builds (#1260)
Full debug info made up most of target/: each worktree's build output was 15–30 GB. Dev builds now keep line tables for workspace crates (file:line stays in backtraces) and no debug info for dependencies, which brings a cold `cargo build` to about 9 GB and roughly halves cold build time. A debugger can't show locals any more; re-enable with `--config profile.dev.debug=true` when needed.

Co-authored-by: LittleGuava <[email protected]>
2026-11-05 09:52:54 +08:00
l0ng-ai 59071ac426 fix(vfs): preserve removals of files discovered by scans (#1412) 2026-11-05 09:51:38 +08:00
Fabio Nunes ad27a7064a fix(security): keep saved passwords in the OS credential store (#910)
SSH passwords and key passphrases, git HTTPS credentials, the HTTP proxy
password and data source passwords were kept in plaintext JSON files in
the app data directory. They now live in the OS credential store via
keyring 4 (macOS Keychain, Windows Credential Manager, Secret Service on
Linux); each file keeps only an index of keys under a per-directory id.

Files from older versions are moved key by key on first use, with a
.migrating sidecar so a failed or interrupted move retries without
duplicating credentials. Without a credential store (headless Linux)
the old file is still read but nothing new is written to disk; the
secret lasts for the session. New data sources no longer save their
password or SSH tunnel secret unless asked.

Also keeps the index path in SecretStore for global data sources and
runs their test against the in-memory store.

Fixes #740 (credentials part)

Co-authored-by: LittleGuava <[email protected]>
2026-11-05 09:47:19 +08:00
Jaw 116b1624b3 fix(workbench): narrow the icon-only tool window stripe (#1405)
The icon-only tool window stripes were 44px wide against a 34px bottom frame, and compact mode shrank the status bar but not the stripes. Icon-only stripe buttons now follow a --stripe-icon-button token (28px, 24px in compact mode) with a 4px outer margin, so the side frames are 38px (34px compact). Dragging the stripe edge stops at the same width. The unused --h-stripe-button token is removed; the stripe with names is unchanged.

Co-authored-by: jaw52 <[email protected]>
2026-11-05 09:35:53 +08:00
l0ng-ai de0f9d6ecd feat(database): global data sources listed in every project (#1407)
Data sources can be made global (Global option in their properties): stored in data-sources.toml of the app data directory, listed in every project, with their saved passwords under an app-wide key that moves along when the scope changes. Fixes #1252
2026-11-05 09:24:23 +08:00
l0ng-ai 6d55eb4aa9 fix(plugins): hash package paths with forward slashes on every OS (#1315)
package_hash fed each package-relative path to the digest with
to_string_lossy, so a walked path hashed as `web\index.html` on Windows
and `web/index.html` elsewhere. The same package had two digests: a
market index built on Linux failed verification on Windows, and a
package signed on one OS did not verify on another.

Join path components with `/` before hashing. Digests on macOS and
Linux are unchanged byte for byte; installed plugins keep their recorded
.package-hash, and a recomputed digest is accepted as a silent update by
the grant ledger, so existing Windows installs are not re-prompted.

Closes #1258
2026-11-05 09:20:42 +08:00
l0ng-ai a247f1ce67 feat(vcs): draw lane changes in the Git log graph as straight diagonals (#1406)
Lane changes bent within half a row as S-curves. A lane change and the vertical half of the neighbouring row that continues it now meet halfway between the lanes, so edges between adjacent rows run straight from node to node; only drawn positions change. Fixes #800
2026-11-05 09:12:41 +08:00
l0ng-ai f16ec29f4a fix(dev): rewrite src/bindings.ts only when the bindings change (#1404)
Debug launches (also second instances that only hand over a path) exported the bindings and rewrote an unchanged src/bindings.ts, which made Vite reload the running app's windows. The bindings now go to a temporary file first and the source file is replaced only when it differs.
2026-11-05 09:05:42 +08:00
ntbang0901 02d30b1609 fix(editor): keep the caret and view when a file changes in distant places (#1329)
Document::diff only trimmed the common prefix and suffix, so two distant
edits on disk became one change spanning everything between them. The
caret and scroll position inside that span were mapped to its start,
which made the editor jump to the first edit after an agent, formatter
or git operation rewrote the file.

Diff the changed middle line by line (Myers via similar, 50 ms deadline
with a coarser fallback) and emit one change per edited region, trimmed
to its differing characters. The frontend DocSync path produces the same
per-region changes, so external reloads, local history restores and
formatting keep the caret and view in place.

Closes #1326

Co-authored-by: ntbang0901 <[email protected]>
2026-11-05 09:04:48 +08:00
l0ng-ai 01e09565b7 feat(run): recognize TestNG classes annotated with @Test (#1403)
TestNG's class-level @Test makes the class's public void methods tests; the scanner now recognizes them (excluding @Before*/@After*/@DataProvider/@Factory). Test results locate TestNG failures past org.testng frames and parse TestNG's comparison message. Refs #1368
2026-11-05 08:59:49 +08:00
l0ng-ai 1f70b134de feat(vcs): show the selected commit's details in the Push dialog (#1402)
Selecting one outgoing commit shows its message, revision, author and time above its files; a Group by Directory toggle switches the files between tree and flat list and is remembered. Fixes #1373
2026-11-05 08:57:19 +08:00
l0ng-ai 11fd64f399 feat(java): add the "Serializable class without 'serialVersionUID'" inspection (#1401)
Settings › Editor › Inspections › Java gets the inspection, off by default. When on, jdtls's compiler reports the missing field and offers the add default/generated ID quick fixes. Built-in inspections can now be off by default, the backend resolves the profile in effect, the compiler-defaults file is named after the variant so jdtls restarts with it, and saving inspection settings reconfigures language servers. Fixes #1199
2026-11-05 08:50:59 +08:00
l0ng-ai 8a884b6362 test(project): smoke-test opening each language's fixture (#1398)
Opens each fixtures/ project through the ops the app's commands call and checks the source and test roots from its build file, the index, Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files come and go, and the Maven / Gradle model. Ignored tests run a Maven and a Gradle sync and check virtualenv and JDK detection; the Language smoke java, kotlin and python jobs run them.
2026-11-05 08:49:08 +08:00
l0ng-ai 481d44930f fix(themes): add --syn-global to the Harbor Night scheme and the token reference (#1400)
#1393 added the --syn-global token to tokens.css but not to the Harbor Night scheme (the theme template) or the generated token reference in docs/plugins/themes.md, so the theme tests failed. The scheme gives it the --syn-variable colour, as tokens.css does.
2026-11-05 08:48:39 +08:00
l0ng-ai f5895d0336 test(debug): smoke-test debugging each language's fixture (#1357)
Debugs each language's fixture the way the editor does, against openidea-server and the debugger the IDE installs: a breakpoint on the entry point's marker line, Debug from the file's context, then the paused frame, its variables, Evaluate Expression, Step Over and Resume. Runs per language in the Language smoke workflow. The build model is loaded first, as the Build Tools feature does, so a Gradle Application is debugged through Gradle; Step Over allows a repeated stop on a line (Python 3.12 reports a conditional expression's line twice).
2026-11-05 08:32:11 +08:00
l0ng-ai c3c1d88d10 fix(vcs): let Commit and Shelf tree names use the whole row width (#1396)
Tree names in the Commit and Shelf windows were capped at 75% of the row, so long folder names were truncated with empty space left over. Names now take the whole row and the grey detail after them is truncated first. Fixes #1367
2026-11-05 08:25:51 +08:00
l0ng-ai a0ac988963 fix(debugger): debug uv / poetry run configurations in the project's environment (#1395)
debugpy ran the project manager (`uv`, `poetry`, …) as a module. Configurations run through uv, poetry, pdm, pipenv, hatch or rye now pass the manager as the debuggee's interpreter, keep the script / -m module / tool as the target, and run the adapter and launcher on a plain interpreter (debugLauncherPython, now preferred by the backend for the adapter command).
2026-11-05 08:18:18 +08:00
l0ng-ai d5c4066748 feat(editor): colour global variables (the semantic global modifier) (#1393)
lua-language-server marks globals with the `global` token modifier, but
the editor dropped it, so a global variable looked like a local unless
rainbow highlighting was on. The modifier now sets `cm-sem-global`, drawn
in a new Language Defaults › Identifiers › Global variable colour
(`--syn-global`): library globals keep the predefined colour and calls the
function-call colour. Imported IntelliJ themes and .icls schemes take it
from DEFAULT_GLOBAL_VARIABLE. Built-in schemes default it to the local
variable colour (IntelliJ's fallback for the key), so they look as before
until a scheme sets it.

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

Closes #1334

Co-authored-by: yzPeedro <[email protected]>
2026-11-05 07:56:02 +08:00
Pedro Pessoa 7ffaa0b5da feat(plugins): let plugin tool windows use an SVG icon from the package (#1363)
A view's `icon` could only be a built-in icon name, so a plugin's tool
window button couldn't show the plugin's own mark. Like `fileIcons`, an
`icon` ending in `.svg` is now a path in the package: the host reads and
checks it at install time (a missing file or an SVG with `<script>` refuses
the install), serves it as the `viewIcon/<id>` asset, and the tool window
registers it as an external icon, falling back to the plugin icon if it
can't be loaded. Icon names work as before.

Closes #1335

Co-authored-by: yzPeedro <[email protected]>
2026-11-05 07:48:39 +08:00
l0ng-ai ab2b53ff80 fix(debugger): ignore a failed attach to a JVM that re-listened as its debugger detached (#1380)
A JDWP agent in server mode listens again, on a new port, when its debugger detaches. When the debugger detaches as the JVM exits (kotlin-debug-adapter on VM death, or Stop on a Maven/Gradle debug run), the announcement reached the Run feature, which attached again: the new session replaced the one that had just finished, then failed with "Cannot start debugger". An attach to a run's process within 3 s of a session on it ending now keeps the finished session until it succeeds, and a failure is logged instead of reported, its leftover session removed. Sessions record when they ended.
2026-11-05 07:45:32 +08:00
Daniel AdámekandDanielADK 2ba731f0ec feat(php): switch the PHP language level from the status bar (#1371)
With a PHP file focused, the status bar shows the PHP level Intelephense
checks against (`PHP 7.4`); the tooltip says where it comes from (the
settings, config.platform.php or require.php). Clicking it opens Auto (with
the composer.json version) and 5.6 to 8.5; picking one writes the same
project setting as Settings › Languages & Frameworks › PHP, so diagnostics
follow without reopening the project. The widget is hidden for other files.
For a file of a linked workspace project, the level comes from that
project's settings and the choices are disabled. The menu is also the
action "PHP Language Level".

Closes #1091

Co-authored-by: DanielADK <[email protected]>
2026-11-05 07:45:26 +08:00
devanddevadapter 742e504719 fix(terminal): keep IME commit gating after a composition (#1231)
Typing CJK punctuation such as `;` or `,` with fcitx5 in the Linux
AppImage sent each character twice: WebKitGTK commits it as
insertFromComposition + compositionend without compositionstart, so
xterm's CompositionHelper finalized the previous composition's stale
position and emitted a suffix. ImeCommitGate was meant to drop that second
emission, but compositionStart() disabled it for the rest of the session
and cancelled its rebase. The gate now works per commit: compositionstart
stands down only for its own composition, every keydown 229 starts a fresh
commit, and the textarea is rebased after each commit.

A keydown the IME did not take (Enter, an arrow, a letter outside the IME)
ends the open commit, so a key pressed right after a directly committed
punctuation mark is not dropped by the commit's timeout.

Closes #1230

Co-authored-by: devadapter <[email protected]>
2026-11-05 07:35:24 +08:00
l0ng-ai ebe059e00f fix(run): run a Python module that imports relatively as a module of its package (#1369)
Run or Debug from a Python file's context started it as a script
(`python src/tasks/cli.py`). A module of a package that imports relatively
then fails with "attempted relative import with no known parent package",
and the debugger stops on that exception.

A file with a relative import (`from .x import y`, `from . import x`) now
runs as `python -m <package>.<module>` from the folder its top package sits
in: the last `src` of its path, else the Python project's folder. A
package's `__main__.py` runs as the package. The project's interpreter is
kept (a relative venv path is adjusted to the new working directory), and
the debugger launches it with debugpy's `module`. Files without relative
imports run as scripts as before.

Fixes #1337
2026-11-05 07:30:13 +08:00
l0ng-ai e17e59ce7d fix(run): write Gradle init scripts on the host that runs the build (#1377)
The test-events and delegated-run init scripts were written to a path built from the desktop's temporary directory, which doesn't exist on a remote host. A new `run_gradle_init_script` command writes a script under the host's own temporary directory, named by its content, and returns the path Gradle reads it from.
2026-11-05 07:25:53 +08:00
l0ng-ai 8021a8ccca fix(debugger): evaluate console input as expressions under CodeLLDB (#1375)
CodeLLDB runs debug-console input as LLDB commands unless its console mode says otherwise, so Evaluate Expression, the Debug Console and breakpoint log messages failed with "'x' is not a valid command". Launch and attach requests now set `_adapterSettings.consoleMode` to `evaluate` unless the configuration picks a mode; LLDB commands still run with a leading backtick.
2026-11-05 07:06:56 +08:00
l0ng-ai 5a6a6f7e05 test(vcs): walk a fixture through edit, commit, branch, stash and a conflicting merge (#1360)
crates/ide-host/tests/vcs_flow.rs takes rust-app through a day with Git
via the ops the app's commands call: an unsaved edit marked in the
gutter against HEAD, Local Changes and the file's diff once saved, a
commit at the head of the log, a branch with its own change, checkout
back with the open file following, a stash pushed and popped, and a
merge that conflicts. Then the open file shows the conflict, the three
versions are offered, and Accept Theirs is committed as a merge.
2026-11-05 06:16:53 +08:00
l0ng-ai a7f0f8110b feat(php): built-in PHP live templates (#1372)
PHP files had no built-in live templates. A PHP group now ships with the
others (Settings › Editor › Live Templates, editable and switchable like
them):

- fore / forek: foreach over an iterable, as value or key => value, the
  caret starting on the iterable
- pubf, prof, prif and pubsf, prosf, prisf: public, protected or private
  method, static or not
- thr (throw new), eco (echo), inc / inco / rqr / rqro (include,
  include_once, require, require_once)

PHP variables are written `$$NAME$` in the bodies, so they expand with
their `$`.

Fixes #1234
2026-11-05 06:06:39 +08:00
l0ng-ai 9669e58398 feat(php): Intelephense stubs for required extensions, Additional stubs and Include paths (#1361)
Intelephense loads only its 68 default stub sets, so code using redis,
imagick, memcached, wordpress and the like showed "Undefined type", and
there was no way to point it at a platform's classes outside the project.

- Each ext-* in require or require-dev of the root composer.json adds its
  stub set, and Settings › PHP › Additional stubs adds others (wordpress).
  `stubs` replaces Intelephense's defaults, so the defaults are sent with
  the additions; nothing is sent when nothing is added.
- Settings › PHP › Include paths: folders (absolute, ~/ or relative to the
  project) sent as environment.includePaths.

Both apply on composer.json and settings changes without reopening the
project; Settings › Language Servers options still win.

Fixes #1232
Fixes #1233
2026-11-05 03:19:54 +08:00
l0ng-ai d939dca303 test(run): smoke-test running and testing each language's fixture (#1332)
* test(run): smoke-test running and testing each language's fixture

smoke/run.test.ts drives the frontend's run store against a real
openidea-server for TypeScript, Python, Rust, Go, Java and Kotlin: the
detected configurations, a run to its output, the tests with the
fixture's failing test switched on (counts, failure name, location and
message in the test tree), and Rerun Failed Tests running only that test.

`pnpm smoke` runs it; the Language smoke workflow runs it per language
after the code intelligence smoke.

* ci(language-smoke): let main's runs finish

Merges to main came faster than a run, so each one cancelled the run
before it: main never finished the smoke nor saved the build cache PRs
restore. Only a PR's runs are superseded now.
2026-11-05 03:02:01 +08:00
l0ng-ai 23378e0cc2 test(lsp): smoke-test parameter info, structure, auto-import, Optimize Imports and file moves (#1355)
The language smoke now also checks, per language and against the
server the app starts: parameter info in a call being typed, the
Structure view's symbols, completing a name that isn't imported yet
(the import comes with it, through the item's edits or its command),
Optimize Imports on a typed import, and moving a file updating the
files that reference it (where the server supports it).
2026-11-05 03:01:07 +08:00
l0ng-ai c83264ae75 fix(debugger): coloured Spring Boot output, Soft-Wrap and Scroll to End in the debug console (#1353)
Debugging a Spring Boot configuration showed its log uncoloured: Run adds
-Dspring.output.ansi.enabled=always (Spring only colours output for a
terminal, the process runs on pipes), the java-debug and Kotlin debug
launches built their VM options without it. They now add the same console
VM options as Run, unless the configuration sets the property itself.

The debug console also gets the Run console's Soft-Wrap toggle (default
from Settings › Console › Use soft wraps) and Scroll to End, which
follows new output, turns off when scrolling up and on again at the end.

Fixes #1262
2026-11-05 02:58:59 +08:00
l0ng-ai 2fc3535300 fix(editor): no lookbehind in the color preview's pattern (#1352)
The color preview's name pattern used a lookbehind, which WebKit before
16.4 cannot parse: on older macOS the module fails to load. The
preceding character of a name is now checked in code, with the same
result (`.red`, `$white`, `off-white` are not colors).
2026-11-05 02:52:44 +08:00
Łukasz Kosickiandkosaa d96ff8e881 feat(i18n): add Polish translations (#1344)
Polish joins the UI languages (Settings › Appearance › Language), with
the key catalog and the source-text catalog; pl / pl-PL system locales
resolve to it.

Co-authored-by: kosaa <[email protected]>
2026-11-05 02:51:25 +08:00
l0ng-ai 3b02e6899d feat(markdown): show YAML front matter as a table in the preview (#1351)
A Markdown file starting with YAML front matter (agent SKILL.md files,
static site pages) showed it in the preview as a thematic break and a run
of text. The preview now shows every top-level field in a key / value
table above the body; nested lists and mappings keep their YAML layout.
Blocks after it keep their source lines for scroll sync. Text between two
thematic breaks that isn't a mapping is rendered as before.

Fixes #1239
2026-11-05 02:48:51 +08:00
fuqiuluoandfuqiuluo 948e93e1a2 fix(search): Find / Replace in Files popup layout, footer and platform shortcuts (#1227)
The Find / Replace in Files popup gets the popup surface and spacing,
the match count and empty states ("Clear option(s)"), the footer with
"Open results in new tab", a resizable popup with a remembered results /
preview split, file mask suggestions from the project's file types
(new index_file_extensions), mnemonics and inline icons. Shortcuts are
spelled for the platform: Linux showed macOS glyphs in the popup, the
shared SearchField, the find bars and Search Everywhere. Replace keeps
its Preview diff.

Closes #1219

Co-authored-by: fuqiuluo <[email protected]>
2026-11-05 02:45:36 +08:00
l0ng-ai 5d19baf06b fix(ssh): report a failed backend upload instead of waiting forever (#1350)
The IDE backend is uploaded by streaming it into `gzip -dc > file` on the
host. When that command exits early (no gzip, a folder it cannot write, a
full disk), it stops reading: once the channel window (about 2 MB) is
used up, the next write waited for window space forever, so Remote
Development stayed on "Installing the IDE backend on the host…" at a few
percent and never showed the host's error. Each write, and closing the
input, now races the command's exit; an early exit ends the upload with
the command's status and stderr.

Refs #1330
2026-11-05 02:39:20 +08:00
l0ng-ai 89309e40ba fix(project): Open in Terminal from the editor opens the edited file's folder (#1348)
Open in Terminal and Reveal in Finder / Explorer invoked without a target
(a shortcut, Find Action) always used the Project view's selection, so in
the editor they opened the folder of whatever was selected there (the
project root after reopening a project) instead of the edited file's.
Without a target they now use the Project view's selection only while it
has focus, and the active editor's file otherwise.

Refs #1327
2026-11-05 02:35:25 +08:00
Shohruh Ismoilovandshohruh-genius 9f2e6b570f feat(vcs): correct Git log graph projection and expand history views (#1076)
Git Log could lose commits when filtering by several paths, draw wrong
ancestry after filtering or collapsing rows, and open the wrong
repository in multi-root views. The graph is now projected over the full
commit DAG and laid out in Bek order, so filtered and collapsed views keep
the real ancestry; each log view keeps its own filters and requests.

Commit metadata, messages and path changes are kept in a persistent index
(by object id, in the IDE's cache directory) with seekable paging, and the
log gains parent-aware merge comparisons, a lazy revision browser,
read-only logs of repositories outside the project (opened with command
execution disabled), commit bookmarks, configurable built-in columns and
batched commit details. The adapted IntelliJ algorithms are attributed in
THIRD_PARTY_LICENSES.md.

Closes #872

Co-authored-by: shohruh-genius <[email protected]>
2026-11-05 02:31:32 +08:00
l0ng-ai 2d20e5a203 fix(lsp): update TypeScript imports when files move (#1346)
vtsls has no workspace/willRenameFiles: after didRenameFiles it updates
imports itself, behind a prompt sent as window/showMessageRequest. The
IDE shows that request as a message without choices, so moving a
TypeScript or JavaScript file never updated the imports of the files
that use it.

For a tsserver-backed server without willRename filters, the IDE now
asks tsserver for the edits up front (getEditsForFileRename through
typescript.tsserverRequest). The edits join the move's preview and undo
like any server's. The server's own update is turned off
(updateImportsOnFileMove.enabled = "never"), so it no longer prompts.
2026-11-05 02:26:28 +08:00
l0ng-ai 7c60737b69 fix(lsp): apply code actions whose follow-up command no server provides (#1343)
vtsls offers Organize Imports as an edit plus
`_typescript.didOrganizeImports`, a command VS Code's TypeScript
extension runs itself. Optimize Imports in a TypeScript file asked the
servers to resolve and run that command and failed with "no language
server provides command" before applying the edit.

A command that follows a code action's edit and that no server provides
is now skipped: the edit is applied, and resolving the command asks
nothing. An action that is only a command still reports it can't run.
2026-11-05 02:19:25 +08:00
Hacbit 7e9acdc59b feat(unity): detect projects and preserve asset metadata (#1207)
Unity projects (a folder with Assets and ProjectSettings/ProjectVersion.txt)
are detected without a generated solution: Assets / Packages become source
roots, ProjectSettings resources, and Unity's cache and build folders are
excluded before they exist. A Unity pane in the Project view shows the
editor version and hides .meta files, generated C# project files and
caches (each can be shown again).

Operations on assets under Assets keep their .meta files consistent:
rename and move carry the metadata (same GUID), delete removes both, copy
generates new GUIDs and updates references inside the copied group, and
file-move undo and workspace edits include the metadata. Destination
metadata conflicts and links inside the project are rejected before
anything changes.

Co-authored-by: hacbit <[email protected]>
2026-11-05 02:16:06 +08:00
l0ng-ai 2013ddaac2 feat(editor): color preview swatches in the gutter (#1341)
Lines with a color literal show a swatch of the (first) color in the
gutter: `#rgb` / `#rrggbb` with or without alpha, `rgb()` / `rgba()` /
`hsl()` / `hsla()`, and CSS named colors in property values of style
sheets. Outside style sheets and markup only quoted hex values and color
functions count, so `#123` issue numbers and `#add` comments get none;
prose files get none at all.

Clicking a swatch in an editable file opens the system color picker; the
picked color replaces the literal in its own format (hex case and length,
the function's name and separators, the original alpha), as one undoable
edit per pick. Settings › Editor › General › Gutter Icons › Color preview
turns the swatches off.

Fixes #1206
2026-11-05 02:10:42 +08:00
l0ng-ai ce898ea0c6 feat(vcs): fill the commit message with the repository's commit template (#1340)
An empty commit message field now starts with the file `commit.template`
names (any git config level; `~/` and repository-relative paths resolve
as git resolves them), without its `#` comment lines, since commits keep
the message as written. It is filled when the repository opens or
changes and when a commit clears the field; a typed message is never
replaced, and a prepared merge / cherry-pick / revert message still
takes precedence.

Fixes #1285
2026-11-05 02:05:07 +08:00
333 changed files with 24353 additions and 1959 deletions

No files matched your search

+17 -15
View File
@@ -15,6 +15,8 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
rust:
@@ -25,10 +27,10 @@ jobs:
os: [macos-latest, ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
@@ -62,7 +64,7 @@ jobs:
name: Remote server (linux musl)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-musl }
- uses: Swatinem/rust-cache@v2
@@ -79,7 +81,7 @@ jobs:
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
@@ -100,9 +102,9 @@ jobs:
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with: { node-version: 22 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
@@ -126,7 +128,7 @@ jobs:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
@@ -135,10 +137,10 @@ jobs:
name: Frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
@@ -154,16 +156,16 @@ jobs:
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm e2e
env:
HOME: /root
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: failure()
with:
name: playwright-report
+31 -4
View File
@@ -2,6 +2,12 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
on:
push:
branches: [main]
@@ -14,12 +20,19 @@ on:
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
cancel-in-progress: true
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -53,9 +66,10 @@ jobs:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- if: matrix.language == 'typescript'
uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
@@ -95,3 +109,16 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v8
with:
script: |
const pr = context.payload.pull_request;
+11 -11
View File
@@ -28,7 +28,7 @@ jobs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- name: Tag matches app version
run: |
@@ -52,7 +52,7 @@ jobs:
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const tag = process.env.TAG;
@@ -99,11 +99,11 @@ jobs:
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -173,7 +173,7 @@ jobs:
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -220,7 +220,7 @@ jobs:
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v5
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
@@ -229,10 +229,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
@@ -277,7 +277,7 @@ jobs:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
@@ -337,7 +337,7 @@ jobs:
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
+7 -2
View File
@@ -69,6 +69,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
@@ -116,8 +121,8 @@ Several worktrees can be developed and run side by side.
default MCP session belongs to this worktree.
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
`target` symlinks at a shared directory.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
Generated
+280 -32
View File
@@ -123,6 +123,17 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
@@ -156,6 +167,27 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "ast-grep-core"
version = "0.40.1"
@@ -1600,6 +1632,7 @@ dependencies = [
"lock_api",
"once_cell",
"parking_lot_core",
"serde",
]
[[package]]
@@ -1795,6 +1828,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -2163,15 +2205,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -4120,7 +4153,7 @@ version = "0.1.36"
dependencies = [
"ide-core",
"ide-run",
"quick-xml",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4272,7 +4305,7 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"quick-xml",
"quick-xml 0.42.0",
"regex-syntax",
"reqwest",
"rustix",
@@ -4514,7 +4547,7 @@ dependencies = [
"libc",
"parking_lot",
"portable-pty",
"quick-xml",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4585,6 +4618,8 @@ dependencies = [
"dirs 6.0.0",
"flate2",
"ide-core",
"keyring",
"keyring-core",
"parking_lot",
"russh",
"russh-config",
@@ -4595,6 +4630,7 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -4634,6 +4670,7 @@ dependencies = [
"proptest",
"ropey",
"serde",
"similar",
"specta",
]
@@ -4662,12 +4699,14 @@ dependencies = [
name = "ide-vcs"
version = "0.1.36"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"gix",
"ide-core",
"ignore",
"libc",
"redb",
"regex-lite",
"serde",
"serde_json",
@@ -4688,7 +4727,7 @@ dependencies = [
"globset",
"ide-core",
"ignore",
"notify-debouncer-full",
"notify",
"parking_lot",
"serde",
"serde_json",
@@ -4735,6 +4774,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
"zip",
]
@@ -5212,6 +5252,33 @@ dependencies = [
"serde",
]
[[package]]
name = "keyring"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
dependencies = [
"chrono",
"dashmap",
"log",
"regex",
"ron",
"serde",
"uuid",
]
[[package]]
name = "kqueue"
version = "1.2.1"
@@ -5634,19 +5701,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
@@ -5675,6 +5729,30 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint 0.4.8",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
@@ -5687,6 +5765,15 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -5702,6 +5789,27 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint 0.4.8",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -6590,7 +6698,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
dependencies = [
"base64 0.23.1",
"indexmap 2.14.2",
"quick-xml",
"quick-xml 0.42.0",
"serde",
"time",
]
@@ -6635,6 +6743,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "poly1305"
version = "0.9.1"
@@ -6895,6 +7009,15 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -7172,18 +7295,18 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7204,6 +7327,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
dependencies = [
"bitflags 2.13.2",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "ropey"
version = "1.6.1"
@@ -7270,7 +7406,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint",
"num-bigint 0.5.1",
"p256",
"p384",
"p521",
@@ -7601,6 +7737,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -7651,6 +7793,25 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secret-service"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hybrid-array",
"num",
"once_cell",
"serde",
"sha2 0.11.1",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -9139,7 +9300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -9327,6 +9488,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -10663,6 +10825,66 @@ dependencies = [
"wasmtime",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.106"
@@ -10948,6 +11170,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -11474,6 +11709,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
@@ -11484,6 +11720,17 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
@@ -11689,6 +11936,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
+9 -1
View File
@@ -76,7 +76,6 @@ ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
trash = "5"
blake3 = "1"
dunce = "1"
@@ -106,6 +105,9 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
keyring = "4.2"
keyring-core = "1"
# testing
insta = { version = "1", features = ["yaml"] }
@@ -115,8 +117,14 @@ tempfile = "3"
[profile.dev]
# Keep debug builds snappy while giving dependencies decent speed.
opt-level = 0
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
# Dependency frames keep function names only. Re-enable one with
# `--config 'profile.dev.package.<crate>.debug=true'`.
debug = false
[profile.release]
codegen-units = 1
+9
View File
@@ -52,6 +52,15 @@ Their original copyright headers and Apache-2.0 licensing are retained.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform VCS log graph algorithms
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
graph layout and LinearBek merge linearization algorithms from
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -111,4 +128,20 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
+6
View File
@@ -716,6 +716,12 @@ pub struct PhpSettings {
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+7 -1
View File
@@ -72,7 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
&["-m", "debugpy.adapter"],
"stdio",
"pip install debugpy",
),
"js-debug" => cfg(
"js-debug-adapter",
// Without a host it listens on `localhost`, which may resolve to ::1 only.
+18 -6
View File
@@ -589,11 +589,12 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
if config.adapter == "debugpy" {
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
@@ -651,8 +652,19 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
+10 -6
View File
@@ -111,8 +111,8 @@ impl Session {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
let native = windows_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
@@ -133,7 +133,11 @@ impl Session {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
windows_source_path(path)
} else {
PathBuf::from(path)
};
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
@@ -265,8 +269,8 @@ impl Session {
}
}
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
@@ -274,7 +278,7 @@ fn is_windows_source_path(path: &str) -> bool {
|| path.starts_with(r"\\")
}
fn java_source_path(path: &str) -> PathBuf {
fn windows_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
+48
View File
@@ -698,6 +698,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
@@ -1110,6 +1141,23 @@ async fn kotlin_official_release_ten_no_delay_runs() {
}
}
#[cfg(windows)]
#[tokio::test]
async fn netcoredbg_windows_source_paths_round_trip() {
let client = "D:/work/LoginController.cs";
let native = r"D:\work\LoginController.cs";
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "netcoredbg".into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
+1
View File
@@ -29,3 +29,4 @@ uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+153 -15
View File
@@ -213,6 +213,8 @@ pub struct DbManager {
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
global: Option<(PathBuf, SecretStore)>,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
@@ -339,6 +341,7 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -351,8 +354,42 @@ impl DbManager {
}
}
/// Also lists the global data sources stored in `dir` (the app data directory).
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
let passwords = self.saved_passwords.scoped(&dir);
self.global = Some((dir, passwords));
self
}
/// The project's data sources, then the global ones (a project one wins on the same id).
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
source::load(&self.root)
let mut all = source::load(&self.root)?;
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
all.extend(global);
Ok(all)
}
fn global_sources(&self) -> Vec<DataSource> {
let Some((dir, _)) = &self.global else { return Vec::new() };
source::load_global(dir).unwrap_or_else(|e| {
tracing::warn!("global data sources: {}", e.message);
Vec::new()
})
}
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
fn store_of(&self, source: &DataSource) -> &SecretStore {
match &self.global {
Some((_, store)) if source.global => store,
_ => &self.saved_passwords,
}
}
/// The connection settings of target `target` ([`DataSource::resolved`]), with
@@ -372,18 +409,43 @@ impl DbManager {
}
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
source::save(&self.root, &sources)?;
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
gone_or_changed => {
if gone_or_changed.is_none() {
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
self.session_passwords.lock().remove(&k);
self.saved_passwords.delete(&k);
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
match gone_or_changed {
None => {
for k in &keys {
self.session_passwords.lock().remove(k);
self.store_of(&o).delete(k);
}
}
// Made global or project-level: its saved passwords move along.
Some(n) if n.global != o.global => {
for k in &keys {
if let Some(p) = self.store_of(&o).get(k) {
self.store_of(n).set(k, &p)?;
self.store_of(&o).delete(k);
}
}
}
Some(_) => {}
}
self.disconnect_source(&o.id).await;
}
@@ -395,7 +457,7 @@ impl DbManager {
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
self.remember(id.to_owned(), password, save).await
self.remember(id, id.to_owned(), password, save).await
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
@@ -407,10 +469,10 @@ impl DbManager {
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(secrets::ssh_key(id), secret, save).await
self.remember(id, secrets::ssh_key(id), secret, save).await
}
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
match &secret {
Some(p) => {
self.session_passwords.lock().insert(key.clone(), p.clone());
@@ -419,10 +481,11 @@ impl DbManager {
self.session_passwords.lock().remove(&key);
}
}
let store = self.saved_for(id);
let result = match (secret, save) {
(Some(p), true) => self.saved_passwords.set(&key, &p),
(Some(p), true) => store.set(&key, &p),
_ => {
self.saved_passwords.delete(&key);
store.delete(&key);
Ok(())
}
};
@@ -433,18 +496,18 @@ impl DbManager {
Ok(())
}
async fn secret(&self, key: String, saved: bool) -> Option<String> {
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
if let Some(p) = self.session_passwords.lock().get(&key) {
return Some(p.clone());
}
if !saved {
return None;
}
self.saved_passwords.get(&key)
self.store_of(source).get(&key)
}
async fn password(&self, source: &DataSource) -> Option<String> {
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
@@ -500,7 +563,7 @@ impl DbManager {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
};
let target = SshTarget {
host: ssh.host.clone(),
@@ -1098,6 +1161,81 @@ impl DbManager {
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::files_containing;
use std::path::Path;
fn manager(dir: &Path) -> DbManager {
ide_ssh::secrets::testing::use_memory_store();
let data = dir.join("data");
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
}
#[tokio::test]
async fn saved_passwords_stay_off_disk() {
let tmp = tempfile::tempdir().unwrap();
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
let reopened = manager(tmp.path());
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
assert_eq!(
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
Some("hunter2-tunnel")
);
}
#[tokio::test]
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project").to_string_lossy().into_owned();
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
let m = manager(tmp.path());
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
}
#[tokio::test]
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
let manager = |root: &std::path::Path| {
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
.with_global_sources(app.clone())
};
let (a, b) = (manager(&one), manager(&two));
let source = |id: &str, global: bool| DataSource {
id: id.into(),
name: id.into(),
driver: DbDriver::Postgres,
save_password: true,
global,
..Default::default()
};
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
a.set_password("shared", Some("pw".into()), true).await.unwrap();
// Made global: listed in the other project too, with its saved password.
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
assert_eq!(ids(&b), [("shared".into(), true)]);
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
// Made project-level again: gone from the other project, the password stays with it.
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
assert!(b.sources().unwrap().is_empty());
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
}
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
+31 -55
View File
@@ -1,20 +1,15 @@
//! Saved data source passwords, in the app's data directory rather than the OS
//! credential store (no system prompts). One JSON file for all projects, keyed
//! by project root, then credential key; owner-only on Unix.
//! Saved data source passwords, in the OS credential store like the other saved
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
//! keyed by project root, then credential key.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use ide_core::IdeResult;
use ide_ssh::secrets::SecretFile;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Project root → credential key → secret.
type Contents = BTreeMap<String, BTreeMap<String, String>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct SecretStore {
path: PathBuf,
file: SecretFile,
project: String,
}
@@ -23,61 +18,41 @@ pub fn ssh_key(id: &str) -> String {
format!("{id}#ssh")
}
fn file_key(project: &str, key: &str) -> String {
format!("{project}|{key}")
}
/// Older versions' file: project root → credential key → secret.
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
Some(
all.into_iter()
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
.collect(),
)
}
impl SecretStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
Self { path, project: root.to_string_lossy().into_owned() }
let file = SecretFile::with_legacy(path.clone(), legacy);
Self { path, file, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
}
pub fn get(&self, key: &str) -> Option<String> {
let _g = LOCK.lock();
self.read().remove(&self.project)?.remove(key)
self.file.get(&file_key(&self.project, key))
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
let _g = LOCK.lock();
let mut all = self.read();
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
self.write(&all)
self.file.set(&file_key(&self.project, key), secret)
}
pub fn delete(&self, key: &str) {
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return };
if project.remove(key).is_none() {
return;
}
if project.is_empty() {
all.remove(&self.project);
}
if let Err(e) = self.write(&all) {
tracing::warn!("saved passwords: {}", e.message);
}
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
}
let tmp = self.path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
self.file.delete(&file_key(&self.project, key))
}
}
@@ -87,6 +62,7 @@ mod tests {
#[test]
fn keeps_projects_apart_and_drops_empty_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("db-passwords.json");
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
+42 -10
View File
@@ -1,6 +1,7 @@
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
//! only in memory for the session.
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
//! the others only in memory for the session.
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
use serde::{Deserialize, Serialize};
@@ -181,6 +182,9 @@ pub struct DataSource {
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
}
impl Default for DataSource {
@@ -209,6 +213,7 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -397,9 +402,32 @@ struct SourcesFile {
sources: Vec<DataSource>,
}
/// The project's data sources.
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, FILE);
let text = match std::fs::read_to_string(&path) {
read(&project_dir::path(root, FILE))
}
/// Saves the project's data sources.
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&project_dir::path(root, FILE), sources)
}
/// The global data sources stored in `dir` (the app data directory).
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
let mut sources = read(&dir.join(FILE))?;
for s in &mut sources {
s.global = true;
}
Ok(sources)
}
/// Saves the global data sources in `dir`.
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&dir.join(FILE), sources)
}
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
@@ -408,19 +436,23 @@ pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
Ok(file.sources)
}
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
for s in sources {
if s.id.is_empty() || s.name.trim().is_empty() {
return Err(IdeError::invalid("every data source needs an id and a name"));
}
}
let path = project_dir::path(root, FILE);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
.map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(path, text)?;
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
table.remove("global");
}
}
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
Ok(())
}
+23 -1
View File
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(hashed_path(rel).as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
@@ -372,6 +372,12 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
fn hashed_path(rel: &Path) -> String {
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -480,4 +486,20 @@ mod tests {
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
#[test]
fn hashes_paths_with_forward_slashes_on_every_os() {
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
#[cfg(windows)]
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("web")).unwrap();
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
// A walked path (native separators) hashes like the manifest's `/` spelling.
assert_eq!(
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
);
}
}
+124 -54
View File
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
use ide_run::ProcessManager;
use ide_terminal::TerminalManager;
use ide_workspace::SettingsStore;
use parking_lot::RwLock;
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
@@ -74,15 +74,23 @@ pub struct Host {
pub environment: environment::Environment,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
}
/// How long exit waits for project shutdown before giving up.
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
@@ -102,6 +110,7 @@ impl Host {
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
@@ -122,8 +131,10 @@ impl Host {
owner,
runtime,
projects: RwLock::default(),
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
};
host.apply_proxy();
host
@@ -177,45 +188,69 @@ impl Host {
}
}
/// Closes `window`'s project and waits until its language servers, kernels and
/// debug sessions have shut down. Never call this on the UI event-loop thread:
/// the shutdown runs on the async runtime, whose workers may themselves be
/// waiting for the event loop (IPC responses, window queries). Use
/// [`Self::close_project_detached`] there.
pub fn close_project(&self, window: &str) {
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
if let Some(shutdown) = self.begin_close(window) {
self.wait_blocking(shutdown);
}
}
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
/// Like [`Self::close_project`], but returns once the project is detached from
/// the window; its workers finish shutting down in the background, and
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
pub fn close_project_detached(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
let mut closing = self.closing.lock();
closing.retain(|handle| !handle.is_finished());
closing.push(shutdown);
}
}
/// Detaches the project from `window` and stops what it started, synchronously,
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and its language
// servers shut down.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
Some(self.spawn_project_shutdown(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
))
}
/// Project/window destruction must finish owned kernel cleanup, not enqueue
/// work which can be lost as the desktop runtime exits: the returned thread is
/// joined by the closer or, for a detached close, before exit.
fn spawn_project_shutdown(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
}
@@ -223,25 +258,37 @@ impl Host {
service.stop_admission();
}
let runtime = self.runtime.clone();
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
})
}
/// Waits for detached closes, so a project opened next never races the
/// language servers of one still shutting down (e.g. over a shared workspace).
pub(crate) fn wait_detached_closes(&self) {
let pending: Vec<_> = self.closing.lock().drain(..).collect();
for shutdown in pending {
self.wait_blocking(shutdown);
}
}
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
let wait = move || {
if let Err(error) = shutdown.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
@@ -253,15 +300,32 @@ impl Host {
}
}
/// Called before the desktop runtime exits, including update/restart exits.
/// Called before the desktop runtime exits, including update/restart exits, on
/// the event-loop thread: waits for detached closes and the open projects'
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
/// stuck on the event loop cannot keep the process from exiting.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
self.shutdown_project_workers_blocking(
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
pending.push(self.spawn_project_shutdown(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
if std::time::Instant::now() >= deadline {
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for handle in pending {
if let Err(error) = handle.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
}
}
/// Called before desktop exit, which may bypass document destructors.
@@ -311,14 +375,20 @@ impl Host {
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
let token = self.begin_project_read_request(window, id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
/// Read-only work remains cancellable/disposed with its window in restricted projects.
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = project.lsp.virtual_document(&uri).await?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
+10 -3
View File
@@ -137,16 +137,23 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
}
}
ide_vfs::delete(&path)
ide_workspace::file_ops::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_vfs::copy(&from, &to)).await
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+26 -1
View File
@@ -260,6 +260,9 @@ pub async fn lsp_implementations(
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -438,12 +441,30 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command {
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
async fn is_client_follow_up(
project: &crate::project::Project,
doc: DocId,
server: Option<ide_core::ServerId>,
command: &Command,
) -> bool {
let provided = project.lsp.provides_command(doc, server, &command.command).await;
if !provided {
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
}
!provided
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -676,6 +697,7 @@ pub async fn lsp_execute_code_action(
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -683,6 +705,9 @@ pub async fn lsp_execute_code_action(
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
Ok(Vec::new())
}
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
+7 -1
View File
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,6 +32,11 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
@@ -160,6 +165,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
+9 -1
View File
@@ -17,6 +17,8 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
pub fn info(project: &Project) -> ProjectInfo {
@@ -33,6 +35,7 @@ pub fn info(project: &Project) -> ProjectInfo {
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -172,7 +175,12 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+18
View File
@@ -142,6 +142,21 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -308,6 +323,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
+40
View File
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+239 -22
View File
@@ -12,6 +12,76 @@ use std::path::{Path, PathBuf};
use crate::apply_git_executable;
/// Read-only repository access also works in a standalone log window.
struct LogAccess {
project: Option<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
cache: PathBuf,
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
repo: PathBuf,
revision: String,
path: PathBuf,
}
let encoded = uri
.strip_prefix("git-revision:")
.and_then(|s| s.split('/').next())
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
}
let bytes = (0..encoded.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&encoded[i..i + 2], 16)
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
})
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
uri: uri.into(),
language_id: host.languages.detect(&target.path, text.lines().next()),
text,
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
})
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
let external = host
.external_logs
.read()
.get(host.owner_window(window))
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
}
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(repo) = &self.external {
repo.enable_log_index(&self.cache);
return f(repo);
}
self.project
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -182,8 +252,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -197,9 +267,21 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window)?;
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
blocking(move || {
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
&path,
rev.as_deref(),
encoding_rs::UTF_8,
)
})
.await
}
@@ -246,8 +328,124 @@ pub async fn vcs_block_history(
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
let project = LogAccess::new(host, window, repo.as_deref());
let request = query.request;
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
})
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
pub async fn vcs_log_refs(
host: &Host,
window: &str,
repo: Option<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
}
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
}
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
let path = dunce::canonicalize(path)?;
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
if let Ok(project) = host.project(window) {
project.open_external_log(&root)?;
}
let mut logs = host.external_logs.write();
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
if !roots.iter().any(|r| r.root() == root) {
roots.push(std::sync::Arc::new(repo));
}
Ok(root.into())
}
pub async fn vcs_log_details(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
}
pub async fn vcs_log_details_batch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_log_index(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
})
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
}
pub async fn vcs_revision_files(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
}
pub async fn vcs_commit_files(
@@ -256,8 +454,8 @@ pub async fn vcs_commit_files(
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -267,13 +465,13 @@ pub async fn vcs_commits_changes(
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
}
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -628,8 +826,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
@@ -690,8 +888,8 @@ pub async fn vcs_create_tag(
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
@@ -727,8 +925,8 @@ pub async fn vcs_rebase_with(
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -815,8 +1013,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -827,8 +1025,8 @@ pub async fn vcs_diff_revisions(
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
@@ -1015,6 +1213,25 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log_details", |c| async move {
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
d.add(
"vcs_log_index",
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
);
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
d.add("vcs_revision_files", |c| async move {
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
+11
View File
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
+10
View File
@@ -165,6 +165,16 @@ impl PluginHost {
self.save_state();
}
/// The marketplace's plugins: read again when `refresh`, else the last index
/// read. Reads it when none has been read yet (first run, nothing cached).
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
self.refresh_market()
} else {
Ok(self.market_plugins())
}
}
/// Reads the index again, keeps it, and applies its revocations. Announces
/// updates of installed plugins it hasn't announced yet.
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
+2
View File
@@ -192,6 +192,8 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
+10
View File
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+70 -8
View File
@@ -34,6 +34,8 @@ pub struct Project {
pub lsp: Arc<LspManager>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Explicitly opened external logs remain read-only and outside project indexing.
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -86,6 +88,7 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
@@ -207,6 +210,7 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
@@ -462,6 +466,7 @@ impl Host {
index,
lsp,
repos,
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -516,12 +521,15 @@ impl Host {
root: &Path,
project_key: &str,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
));
let db = Arc::new(
ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
)
.with_global_sources(self.data_dir.clone()),
);
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
@@ -563,6 +571,7 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
let effective = settings.effective();
@@ -595,6 +604,7 @@ impl Host {
index,
lsp,
repos: Arc::default(),
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
@@ -692,7 +702,14 @@ fn scan_repositories(
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
let name = n.to_string_lossy();
ignored.is_ignored_name(&name)
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
&& p.parent().is_some_and(ide_workspace::unity::is_project))
})
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
@@ -739,6 +756,51 @@ impl Project {
}
}
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
let root = dunce::canonicalize(path)?;
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
let mut external = self.external_log_repos.write();
if !external.iter().any(|r| r.root() == root) {
external.push(Arc::new(repo));
}
Ok(root)
}
/// Log read operations may also use an explicitly opened external worktree.
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(path) = at
&& self.repos().at_root(path).is_none()
{
let repo = self
.external_log_repos
.read()
.iter()
.find(|r| r.root() == path)
.cloned()
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
return f(&repo);
}
self.with_repo_at(at, f)
}
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if self.repos().for_path(path).is_some() {
return self.with_repo_of(path, f);
}
let repo = self
.external_log_repos
.read()
.iter()
.filter(|r| path.starts_with(r.root()))
.max_by_key(|r| r.root().components().count())
.cloned()
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
f(&repo)
}
/// Runs `f` with the repository `path` (a file or folder) belongs to.
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
match self.repos().for_path(path) {
@@ -955,7 +1017,7 @@ impl Project {
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
+66 -4
View File
@@ -1,6 +1,7 @@
//! System Settings › HTTP Proxy on the host: the global settings made current for
//! connections and child processes, the password kept in the saved passwords
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
//! (the OS credential store) instead of `settings.toml`, or for the session
//! when there is no credential store.
use crate::Host;
use ide_core::IdeResult;
@@ -12,7 +13,9 @@ impl Host {
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
// A password typed into settings.toml by hand counts too, unless one was saved.
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
let session = || self.proxy_session_password.lock().clone();
// The session copy is the latest write that could not be saved.
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
settings.proxy.password = password;
}
settings
@@ -40,11 +43,70 @@ impl Host {
_ => return Ok(Some(value)),
};
if let Some(password) = password {
match password.as_str().filter(|p| !p.is_empty()) {
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
let password = password.as_str().filter(|p| !p.is_empty());
let mut session = self.proxy_session_password.lock();
*session = None;
match password {
Some(p) => {
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
*session = Some(p.to_owned());
}
}
None => self.proxy_password.delete(PASSWORD_KEY),
}
}
Ok((key != "proxy.password").then_some(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
use std::sync::Arc;
/// A machine without a credential store (a headless Linux host).
struct NoStore;
impl Vault for NoStore {
fn available(&self) -> bool {
false
}
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
Err(StoreError::NoDefaultStore)
}
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
}
#[tokio::test]
async fn without_a_credential_store_the_password_lasts_the_session() {
let tmp = tempfile::tempdir().unwrap();
let mut host = Host::new(crate::HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let path = tmp.path().join("data/proxy-password.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
assert_eq!(left, None);
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ impl Dispatcher {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
+9
View File
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_current"
| "project_roots"
| "index_status"
| "index_file_extensions"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
@@ -307,6 +308,14 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_blame"
| "vcs_block_history"
| "vcs_log"
| "vcs_log_refs"
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
| "vcs_commit_files"
| "vcs_commits_changes"
| "vcs_branches"
+51
View File
@@ -5,6 +5,7 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
@@ -52,3 +53,53 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
host.terminals.kill_key("other");
});
}
/// The desktop closes a project on the event-loop thread while runtime workers may be
/// blocked waiting for that thread (IPC responses, window queries): closing must not
/// wait for workers that need the runtime, and exit still waits for them once free.
#[test]
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// Occupy the only runtime worker until "the event loop" answers.
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
runtime.spawn(async move {
started_tx.send(()).unwrap();
let _ = release_rx.recv();
});
started_rx.recv().unwrap();
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
let closer = host.clone();
std::thread::spawn(move || {
closer.close_project_detached("main");
let _ = closed_tx.send(());
});
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
release_tx.send(()).unwrap();
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
assert!(host.project("main").is_err(), "the window no longer has the project");
host.open_project("main", &root).expect("the window opens a project once the close has finished");
let started = Instant::now();
host.shutdown_notebooks_on_exit();
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
}
+22
View File
@@ -196,6 +196,28 @@ fn write_index(
path.to_string_lossy().into_owned()
}
/// First run: nothing cached yet, so listing the marketplace reads its index
/// instead of showing an empty one; later listings use the index read.
#[test]
fn the_first_marketplace_listing_reads_the_index() {
let f = fixture();
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let url = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(url.clone()));
let listed = f.host.market_listing(false).unwrap();
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
std::fs::remove_file(&url).unwrap();
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
}
#[test]
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
let f = fixture();
+115
View File
@@ -0,0 +1,115 @@
//! Explicit external logs allow history reads without adding a writable project root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(root)
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_history_is_readable_and_never_becomes_a_write_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let external = base.join("external");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&external).unwrap();
git(&external, &["init", "-b", "main"]);
let deleted = external.join("deleted.txt");
std::fs::write(&deleted, "historical content\n").unwrap();
git(&external, &["add", "."]);
git(&external, &["commit", "-m", "initial"]);
let first = git(&external, &["rev-parse", "HEAD"]);
git(&external, &["rm", "deleted.txt"]);
git(&external, &["commit", "-m", "delete"]);
let head = git(&external, &["rev-parse", "HEAD"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
// A standalone editor needs no project, indexer, or writable VCS root.
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(standalone[0].id, head);
let details = vcs::vcs_log_details_batch(
&host,
"standalone",
Some(external.clone()),
vec![head.clone(), first.clone()],
ide_core::RequestId(987),
)
.await
.unwrap();
assert!(details.iter().all(|d| d.signature == "N"));
assert_eq!(
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
.await
.unwrap(),
vec!["deleted.txt"]
);
assert!(host.project("standalone").is_err());
host.trust.decide(&external, false, false).unwrap();
let restricted = host.open_project("restricted", &external).unwrap();
assert!(!restricted.is_trusted());
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
let restricted_log = vcs::vcs_log(
&host,
"restricted",
Some(external.clone()),
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
)
.await
.unwrap();
assert_eq!(restricted_log[0].id, head);
assert_eq!(
vcs::vcs_log_details_batch(
&host,
"restricted",
Some(external.clone()),
vec![head.clone()],
ide_core::RequestId(989)
)
.await
.unwrap()[0]
.signature,
"N"
);
host.close_project("restricted");
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
assert_eq!(
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
Some("historical content\n")
);
assert!(project.repos().at_root(&external).is_none());
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
assert!(!deleted.exists());
host.close_project("main");
}
+282 -2
View File
@@ -1,6 +1,7 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! language server the app starts by default.
//!
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
@@ -68,6 +69,35 @@ struct Checks {
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
@@ -432,6 +462,146 @@ impl Smoke {
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
@@ -440,7 +610,17 @@ impl Smoke {
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
@@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks {
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -521,6 +717,19 @@ const PYTHON: Checks = Checks {
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -558,6 +767,16 @@ const RUST: Checks = Checks {
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -597,6 +816,16 @@ const GO: Checks = Checks {
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -653,6 +882,37 @@ const JAVA: Checks = Checks {
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks {
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+77
View File
@@ -2045,6 +2045,83 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
+285
View File
@@ -0,0 +1,285 @@
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
//! the source and test roots detected from its build file, the index (files and symbols),
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
//! come and go, and the Maven / Gradle model.
//!
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
//! java, kotlin and python jobs):
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
use ide_host::{Host, HostConfig, ops};
use ide_index::SymbolFilter;
use ide_workspace::roots::RootKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Fixture {
dir: &'static str,
/// Detected roots: (project-relative path, kind).
roots: &'static [(&'static str, RootKind)],
/// Search Everywhere › Files: (query, file found).
file: (&'static str, &'static str),
/// Go to Class: (name, file declaring it).
class: (&'static str, &'static str),
/// Go to Symbol: (name, file declaring it).
symbol: (&'static str, &'static str),
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
added: (&'static str, &'static str),
}
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
async fn open(fixture: &str) -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
Opened { host, root, _tmp: tmp }
}
/// Polls `check` until it holds; fails with its last answer.
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
let deadline = Instant::now() + timeout;
loop {
match check() {
Ok(()) => return,
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
impl Opened {
fn files(&self, query: &str) -> Vec<String> {
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
found.into_iter().map(|f| f.rel_path).collect()
}
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
}
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
let found = self.symbols(name, filter);
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
}
}
async fn smoke(f: Fixture) {
let p = open(f.dir).await;
until("indexing", Duration::from_secs(30), || {
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
})
.await;
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
for (path, kind) in f.roots {
assert!(
roots.iter().any(|r| r.path == *path && r.kind == *kind),
"{}: no {kind:?} root {path:?} in {roots:?}",
f.dir
);
}
let files = p.files(f.file.0);
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
}
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
}
// The index follows the file system while the project is open.
let (added, text) = f.added;
std::fs::write(p.root.join(added), text).unwrap();
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
.await;
std::fs::remove_file(p.root.join(added)).unwrap();
until("a deleted file's class", Duration::from_secs(20), || {
let found = p.symbols("FreshWidget", SymbolFilter::Types);
if found.is_empty() { Ok(()) } else { Err(found) }
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rust_app() {
smoke(Fixture {
dir: "rust-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("pars", "src/parser.rs"),
class: ("Inventory", "src/lib.rs"),
symbol: ("low_stock", "src/lib.rs"),
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ts_app() {
smoke(Fixture {
dir: "ts-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("cart", "src/cart.ts"),
class: ("Cart", "src/cart.ts"),
symbol: ("formatMoney", "src/money.ts"),
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn python_app() {
smoke(Fixture {
dir: "python-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("models", "src/tasks/models.py"),
class: ("TaskList", "src/tasks/models.py"),
symbol: ("build_demo", "src/tasks/cli.py"),
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn go_app() {
smoke(Fixture {
dir: "go-app",
roots: &[("", RootKind::Source)],
file: ("store", "internal/store/store.go"),
class: ("Store", "internal/store/store.go"),
symbol: ("writeJSON", "cmd/server/main.go"),
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn java_app() {
smoke(Fixture {
dir: "java-app",
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
added: (
"src/main/java/com/example/orders/FreshWidget.java",
"package com.example.orders;\n\npublic class FreshWidget {}\n",
),
})
.await;
let p = open("java-app").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let maven = model.maven.expect("a Maven model");
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn kotlin_gradle() {
smoke(Fixture {
dir: "kotlin-gradle",
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
})
.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!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
}
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
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();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
model
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Maven and a JDK"]
async fn maven_sync() {
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
assert_eq!(maven.sync_error, None);
assert!(maven.synced_at.is_some());
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
// The resolved tree, not only the declaration.
assert!(!junit.children.is_empty(), "{junit:?}");
}
#[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();
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();
// The build's own tasks: the application and Kotlin plugins' too.
for task in ["run", "compileKotlin", "test"] {
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs python3"]
async fn python_venv() {
let p = open("python-app").await;
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
assert!(made.unwrap().success());
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs JDK 21"]
async fn jdk() {
let p = open("java-app").await;
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
}
+2 -1
View File
@@ -43,6 +43,7 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
assert_eq!(proxy().mode, ProxyMode::Auto);
@@ -61,7 +62,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
assert!(saved.contains("s3cret"));
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
// A whole-table write keeps the password out of the file too; an empty one forgets it.
+16
View File
@@ -141,3 +141,19 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
+100
View File
@@ -0,0 +1,100 @@
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops};
use std::{sync::Arc, time::Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("Game");
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
std::fs::create_dir_all(root.join(name)).unwrap();
}
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = ops::project::info(&project);
assert_eq!(info.name, "Game");
let unity = info.unity.unwrap();
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
assert!(project.index.wait_idle(Duration::from_secs(20)));
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
}))
.unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file.rel_path);
}
}),
)
.await
.unwrap();
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
// Project metadata refreshes without reopening when Unity generates its solution.
std::fs::write(root.join("Game.sln"), "").unwrap();
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
let unity = ops::project::info(&project).unity.unwrap();
assert_eq!(unity.solutions, ["Game.sln"]);
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
// Desktop commands and remote RPC share these host operations.
let original = root.join("Assets/Scripts/Player.cs");
let renamed = root.join("Assets/Scripts/Hero.cs");
let copied = root.join("Assets/Scripts/HeroCopy.cs");
let meta = |path: &std::path::Path| -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
host.close_project("main");
let ordinary = base.join("ConsoleApp");
std::fs::create_dir(&ordinary).unwrap();
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
let project = host.open_project("main", &ordinary).unwrap();
assert!(ops::project::info(&project).unity.is_none());
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
host.close_project("main");
}
+209
View File
@@ -0,0 +1,209 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
+63
View File
@@ -175,3 +175,66 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
use ide_lsp::types::{CodeAction, Command};
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("imports.txt");
std::fs::write(&file, "unused\nused\n").unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &file).unwrap();
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
disabled_reason: None,
edit: edit.then(|| {
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
] }] }))
.unwrap()
}),
command: Some(Command {
title: String::new(),
command: "_typescript.didOrganizeImports".into(),
arguments: vec![],
}),
data: None,
server: ide_core::ServerId(1),
};
let follow_up = action(true).command.unwrap();
let asked = ops::lsp::lsp_resolve_command(
&host,
"main",
ide_core::RequestId(1),
info.id,
ide_core::ServerId(1),
follow_up,
vec![],
)
.await;
assert!(matches!(asked, Ok(None)), "{asked:?}");
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
.await
.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
// Without an edit the command is the action: it still can't run.
let ran =
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
assert!(ran.is_err(), "{ran:?}");
}
+51
View File
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
+30
View File
@@ -8,6 +8,7 @@ use ide_language::{LanguageRegistry, Syntax};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -64,6 +65,14 @@ pub struct IndexStatus {
pub symbols: u32,
}
/// How many indexed files share an extension (lower case, without the dot).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionCount {
pub extension: String,
pub files: u32,
}
pub struct ProjectIndex {
shared: Arc<Shared>,
worker: Mutex<Option<JoinHandle<()>>>,
@@ -159,6 +168,27 @@ impl ProjectIndex {
}
}
/// The extensions of the indexed files, most common first: the project's file
/// types, for suggestions such as Find in Files' file masks. Dotfiles
/// (`.gitignore`) have no extension.
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
let d = self.shared.data.read();
let mut counts: HashMap<String, u32> = HashMap::new();
for file in d.files.iter().flatten() {
let name = &file.rel[file.name_start as usize..];
if let Some((stem, extension)) = name.rsplit_once('.')
&& !stem.is_empty()
&& !extension.is_empty()
{
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
}
}
let mut out: Vec<ExtensionCount> =
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
out
}
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
/// matches; `/` in the query matches path segments; recently opened files boost.
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
+22
View File
@@ -111,6 +111,28 @@ fn indexes_files_and_symbols_honouring_excludes() {
assert_eq!(m.path, fx.root.join("src/main.rs"));
}
#[test]
fn counts_file_extensions_most_common_first() {
let fx = Fixture::new(&[
("src/a.rs", "fn a() {}\n"),
("src/b.RS", "fn b() {}\n"),
("src/lib.rs", "fn c() {}\n"),
("Cargo.toml", "[package]\n"),
("README.md", "# r\n"),
("docs/guide.md", "# g\n"),
(".gitignore", "target\n"),
("Makefile", "all:\n"),
("target/debug/gen.rs", "fn generated() {}\n"),
]);
let idx = fx.open();
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
assert_eq!(
counts,
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
"case-folded, excluded folders and extension-less files left out"
);
}
#[test]
fn file_ranking_is_idea_like() {
let fx = sample();
+43 -1
View File
@@ -23,6 +23,7 @@ mod scope;
mod server;
mod tcp;
mod text;
mod ts_file_moves;
mod tsserver_bridge;
pub mod types;
mod uri;
@@ -1692,6 +1693,20 @@ impl LspManager {
Ok(())
}
/// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
/// `_typescript.didOrganizeImports`): no server provides those.
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
};
self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name))
.await
.iter()
.any(|t| server.is_none_or(|s| t.server.id == s))
}
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
let snap = self.snapshot(doc)?;
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
@@ -1710,6 +1725,10 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
// Nothing to ask for a command its server doesn't provide (executing it decides).
if !self.provides_command(doc, Some(server), &command.command).await {
return Ok(None);
}
let t = self.command_target(doc, server, &command.command).await?;
let interactive = t.server.name == "gopls"
&& t.caps()
@@ -1992,7 +2011,30 @@ impl LspManager {
servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect();
let mut out = FileRenameEdits::default();
for t in targets {
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue };
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else {
if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) {
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
for (from, to, _) in &moves {
let r: IdeResult<Value> = self
.call(
&t,
"workspace/executeCommand",
ts_file_moves::request(from, to),
Some(&cancel),
LONG_TIMEOUT,
)
.await;
match r {
Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files),
Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e),
Err(e) => out.errors.push(e.message),
}
}
drop(opened);
}
continue;
};
out.servers.push(t.server.name.clone());
let opened = self.open_for_file_op(&t, &moves);
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
+89
View File
@@ -0,0 +1,89 @@
//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`:
//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The
//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through
//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's
//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`).
use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit};
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::PathBuf;
pub(crate) const COMMAND: &str = "typescript.tsserverRequest";
/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`.
pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value {
json!({
"command": COMMAND,
"arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }],
})
}
#[derive(Deserialize)]
struct Location {
line: u32,
offset: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Change {
start: Location,
end: Location,
new_text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FileChanges {
file_name: PathBuf,
text_changes: Vec<Change>,
}
/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit.
pub(crate) fn edit(result: &Value) -> WorkspaceEdit {
let body = result.get("body").unwrap_or(result);
let files: Vec<FileChanges> = serde_json::from_value(body.clone()).unwrap_or_default();
let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1));
WorkspaceEdit {
files: files
.into_iter()
.filter(|f| !f.text_changes.is_empty())
.map(|f| FileEdit::Edit {
path: f.file_name.into(),
edits: f
.text_changes
.into_iter()
.map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text })
.collect(),
version: None,
})
.collect(),
preconditions: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_tsserver_file_code_edits() {
let result = json!({ "success": true, "body": [
{ "fileName": "/p/src/index.ts", "textChanges": [
{ "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" }
] },
{ "fileName": "/p/src/unchanged.ts", "textChanges": [] }
] });
let edit = edit(&result);
assert_eq!(edit.files.len(), 1);
let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") };
assert_eq!(**path, *std::path::Path::new("/p/src/index.ts"));
assert_eq!(
edits,
&[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }]
);
assert!(version.is_none());
assert!(super::edit(&json!({ "success": false })).files.is_empty());
}
}
+35
View File
@@ -2336,6 +2336,41 @@ async fn will_and_did_rename_files_honour_filters() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
let money = h.root.join("money.ts");
let index = h.root.join("index.ts");
std::fs::write(&money, "export const one = 1;\n").unwrap();
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
let s = h.launcher.state("vtsls");
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
})
}));
let to = h.root.join("lib/money.ts");
let result =
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
assert!(result.errors.is_empty(), "{:?}", result.errors);
match &result.edit.files[..] {
[FileEdit::Edit { path, edits, version: None }] => {
assert_eq!(path, &index);
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
assert_eq!(edits[0].new_text, "./lib/money");
}
other => panic!("unexpected edit {other:?}"),
}
let asked = s.requested("workspace/executeCommand");
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
}
// ------------------------------------------------------------------ inspect code
struct InspectTestHost {
+1
View File
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
+15
View File
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Binary file not shown.
@@ -1 +1 @@
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
Binary file not shown.
@@ -1 +1 @@
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
+1
View File
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
+2
View File
@@ -64,6 +64,8 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
@@ -1 +1 @@
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
+41 -1
View File
@@ -143,7 +143,10 @@ impl Mappings {
}
let (l, rest, _) = best?;
// A `..` from the server must not leave the local folder.
if rest.split('/').any(|s| s == ".." || s == ".") {
if rest.split('/').filter(|s| !s.is_empty()).any(|s| {
let mut components = Path::new(s).components();
!matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some()
}) {
return None;
}
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
@@ -307,6 +310,43 @@ mod tests {
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
}
#[cfg(windows)]
#[test]
fn native_anchors_never_replace_the_project_relative_fallback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let inside = root.join("src");
let outside = tmp.path().join("outside");
let missing = tmp.path().join("missing");
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir(&outside).unwrap();
for slash in ["\\", "/"] {
let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash);
assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone()));
assert_eq!(local_folder(&spelling(&outside), &root), None);
assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing))));
assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone()));
}
}
#[test]
fn remote_paths_cannot_replace_or_leave_a_local_mapping() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let m = Mappings::new(&server(vec![]), root, "/srv");
for remote in ["/srv/../outside", "/srv/./file"] {
assert_eq!(m.local(remote), None, "{remote}");
}
#[cfg(windows)]
for remote in
["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"]
{
assert_eq!(m.local(remote), None, "{remote}");
}
assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file")));
assert_eq!(m.local("/srv"), Some(root.to_path_buf()));
}
#[cfg(unix)]
#[test]
fn absolute_symlink_inside_project_resolves_to_the_project_spelling() {
+82 -10
View File
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
if o.enabled != Some(true) {
return None;
}
Some(match o.severity {
Some(InspectionLevel::Error) => "error",
None | Some(InspectionLevel::Warning) => "warning",
Some(_) => "info",
})
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
/// Each variant has its own file, so changing an inspection restarts the server with it.
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let (name, content) = match serial {
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
Some(level) => (
format!("compiler-defaults-serial-{level}.prefs"),
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
),
};
let file = data_dir.join("lsp/jdtls").join(name);
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
}
Ok(())
};
@@ -612,9 +638,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(version) = crate::php::language_level(root, s)
&& let Some(level) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
merge_init_options(cfg, json!({ "stubs": stubs }));
}
let include_paths = crate::php::include_paths(root, s);
if !include_paths.is_empty() {
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
}
}
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
@@ -1086,13 +1121,50 @@ mod java_compiler_tests {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn serial_version_inspection_sets_the_compiler_option() {
let tmp = tempfile::tempdir().unwrap();
let url = |inspections: &InspectionSettings| {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
configure_java_compiler(&mut cfg, tmp.path(), inspections);
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
};
let set = |o: ide_core::settings::InspectionOverride| {
let mut s = InspectionSettings::default();
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
s
};
let off = url(&InspectionSettings::default());
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
assert_ne!(on, off, "a new file, so the server restarts with it");
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
let error = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
let weak = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::WeakWarning),
..Default::default()
}));
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
let severity_only = url(&set(ide_core::settings::InspectionOverride {
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert_eq!(severity_only, off, "off by default");
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1103,7 +1175,7 @@ mod java_compiler_tests {
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1113,7 +1185,7 @@ mod java_compiler_tests {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path());
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
-1
View File
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+177 -11
View File
@@ -1,24 +1,53 @@
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
//! and the Intelephense storage that goes with it.
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
use crate::apply::{merge, merge_init_options};
use crate::apply::{merge, merge_init_options, setting_path};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
/// `composer.json` name one.
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
let explicit = s.languages.php.language_level.trim();
if !explicit.is_empty() {
return version(explicit).map(dotted);
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
@@ -48,10 +77,74 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
}
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
/// `stubs` replaces them, so additions are sent together with these.
#[rustfmt::skip]
const DEFAULT_STUBS: &[&str] = &[
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
];
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
/// extension name.
const MIXED_CASE_STUBS: &[&str] =
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
fn stub_key(name: &str) -> String {
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
/// that adds nothing to the defaults, so the server keeps its own.
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
let composer: Option<Value> =
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
let extensions = ["require", "require-dev"]
.into_iter()
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
.flat_map(|deps| deps.keys())
.filter_map(|dep| dep.strip_prefix("ext-"))
.map(|ext| {
let key = stub_key(ext);
MIXED_CASE_STUBS
.iter()
.find(|stub| stub_key(stub) == key)
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
});
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
for stub in extensions.chain(configured) {
if !stub.is_empty() && known.insert(stub_key(&stub)) {
stubs.push(stub);
}
}
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
}
/// The folders in Settings › PHP › Include paths, resolved against the project.
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
s.languages
.php
.include_paths
.iter()
.filter_map(|p| setting_path(root, p))
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Version = (u32, u32, u32);
fn dotted((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
}
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
@@ -105,13 +198,18 @@ mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
language_level(tmp.path(), &s)
(language_level(tmp.path(), &s), composer_level(tmp.path()))
}
fn required(constraint: &str) -> Option<String> {
@@ -201,6 +299,74 @@ mod tests {
}
}
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
}
let mut s = Settings::default();
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
intelephense_stubs(tmp.path(), &s)
}
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
stubs[DEFAULT_STUBS.len()..].to_vec()
}
#[test]
fn required_extensions_and_configured_stubs_follow_the_defaults() {
let composer = json!({
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
});
assert_eq!(
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
["redis", "imagick", "FFI", "wordpress"]
);
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
}
#[test]
fn nothing_beyond_the_defaults_sends_nothing() {
assert_eq!(stubs(None, &[]), None);
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
let defaults =
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
}
#[test]
fn include_paths_resolve_against_the_project() {
let root = Path::new("/work/module");
let mut s = Settings::default();
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
assert_eq!(
include_paths(root, &s),
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
+24
View File
@@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() {
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
}
#[test]
fn intelephense_gets_the_required_stubs_and_include_paths() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
let mut s = Settings::default();
s.languages.php.stubs = vec!["wordpress".into()];
s.languages.php.include_paths = vec!["../platform".into()];
let mut cfg = server("intelephense");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(stubs.len(), 70);
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
assert_eq!(
options["environment"],
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
);
let mut other = server("phpactor");
configure_server(&mut other, tmp.path(), tmp.path(), &s);
assert_eq!(other.initialization_options, None);
}
#[test]
fn go_constraints_change_only_protocol_settings() {
use serde_json::json;
+7 -2
View File
@@ -14,15 +14,20 @@ tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
dirs.workspace = true
uuid.workspace = true
keyring.workspace = true
keyring-core.workspace = true
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
russh-config = "0.58.0"
russh-sftp = "2.4"
flate2 = "1"
[features]
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
testing = []
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
# and an in-memory credential store in place of the OS one.
testing = ["keyring-core/sample"]
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
keyring-core = { workspace = true, features = ["sample"] }
+1
View File
@@ -216,6 +216,7 @@ mod tests {
use super::*;
fn store() -> (tempfile::TempDir, GitCredentials) {
crate::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
+499 -31
View File
@@ -1,61 +1,289 @@
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
//! proxy's), in the app's data directory rather than the OS credential store (no
//! system prompts). One JSON object, key → secret; owner-only on Unix.
//! Saved passwords (SSH passwords / key passphrases by configuration id, git HTTPS
//! credentials, the HTTP proxy's) in the OS credential store: Windows Credential
//! Manager, the macOS Keychain, the Secret Service on Linux. The file at `path` only
//! indexes them (an id and the keys, never a secret); owner-only on Unix.
//!
//! Older versions kept the secrets themselves in that file: they are moved to the
//! credential store on first use. Without a credential store (a headless Linux
//! host) such a file is still read and forgetting a secret rewrites it without
//! that one, but no new secret is written to disk: saving fails and callers keep
//! the secret for the session only.
use ide_core::{IdeError, IdeResult};
pub use keyring_core::{Error as StoreError, Result as StoreResult};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Arc;
const SERVICE: &str = "ai.l0ng.openidea";
/// Serialises read-modify-write of the index files (several stores may share one).
static LOCK: Mutex<()> = Mutex::new(());
/// Reads the secrets of an older version's file, by key (`None`: not that format).
pub type Legacy = fn(serde_json::Value) -> Option<BTreeMap<String, String>>;
/// Where the secrets are kept: the default credential store, or a stand-in in tests.
pub trait Vault: Send + Sync {
fn available(&self) -> bool;
fn get(&self, service: &str, key: &str) -> StoreResult<String>;
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()>;
fn delete(&self, service: &str, key: &str) -> StoreResult<()>;
}
/// `keyring_core`'s default store: the OS's, unless a test installed another one.
struct DefaultStore;
impl DefaultStore {
fn entry(&self, service: &str, key: &str) -> StoreResult<keyring_core::Entry> {
if !self.available() {
return Err(StoreError::NoDefaultStore);
}
keyring_core::Entry::new(service, key)
}
}
impl Vault for DefaultStore {
fn available(&self) -> bool {
if keyring_core::get_default_store().is_some() {
return true;
}
static WARNED: std::sync::Once = std::sync::Once::new();
match keyring::Entry::store_status() {
Ok(()) => true,
Err(e) => {
WARNED.call_once(|| tracing::warn!("saved passwords: no credential store: {e}"));
false
}
}
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
self.entry(service, key)?.get_password()
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
self.entry(service, key)?.set_password(secret)
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
self.entry(service, key)?.delete_credential()
}
}
pub struct SecretFile {
path: PathBuf,
lock: Mutex<()>,
legacy: Legacy,
vault: Arc<dyn Vault>,
}
/// The file's contents. `id` keeps the credentials of different data directories
/// (and of tests) apart in the one OS store.
#[derive(Serialize, Deserialize)]
struct Index {
id: String,
keys: BTreeSet<String>,
}
enum Contents {
Index(Index),
/// An older version's file that could not be moved, and why.
Plaintext(BTreeMap<String, String>, String),
}
fn service(id: &str) -> String {
format!("{SERVICE}/{id}")
}
fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
impl SecretFile {
pub fn new(path: PathBuf) -> Self {
Self { path, lock: Mutex::new(()) }
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
}
pub fn with_legacy(path: PathBuf, legacy: Legacy) -> Self {
Self::with_vault(path, legacy, Arc::new(DefaultStore))
}
pub fn with_vault(path: PathBuf, legacy: Legacy, vault: Arc<dyn Vault>) -> Self {
Self { path, legacy, vault }
}
pub fn get(&self, key: &str) -> Option<String> {
let _g = self.lock.lock();
self.read().remove(key)
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
}
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
},
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
let _g = self.lock.lock();
let mut all = self.read();
all.insert(key.to_owned(), secret.to_owned());
self.write(&all)
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
}
pub fn delete(&self, key: &str) {
let _g = self.lock.lock();
let mut all = self.read();
if all.remove(key).is_some()
&& let Err(e) = self.write(&all)
{
tracing::warn!("{}", e.message);
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
}
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
}
index.keys.remove(key);
self.write_logged(&index);
}
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
}
}
/// Every stored key, sorted (the secrets themselves stay private).
pub fn keys(&self) -> Vec<String> {
let _g = self.lock.lock();
self.read().into_keys().collect()
}
fn read(&self) -> BTreeMap<String, String> {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
BTreeMap::new()
}),
Err(_) => BTreeMap::new(),
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
}
}
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
fn load(&self) -> Contents {
let value = match std::fs::read(&self.path) {
Err(_) => return Contents::Index(Index { id: new_id(), keys: BTreeSet::new() }),
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(v) => v,
Err(e) => return self.set_aside(&e.to_string()),
},
};
if let Ok(index) = serde_json::from_value::<Index>(value.clone()) {
return Contents::Index(index);
}
match (self.legacy)(value) {
Some(old) => self.migrate(old),
None => self.set_aside("unknown format"),
}
}
/// An unreadable file is renamed rather than overwritten (it may index
/// credentials, or hold secrets); the store starts over.
fn set_aside(&self, why: &str) -> Contents {
let aside = self.path.with_extension(format!("json.unreadable-{}", new_id()));
tracing::warn!("saved passwords: {}: {why}; moved to {}", self.path.display(), aside.display());
if let Err(e) = std::fs::rename(&self.path, &aside) {
// Not overwritten either: nothing is saved until it can be moved.
return Contents::Plaintext(BTreeMap::new(), format!("{}: {e}", self.path.display()));
}
Contents::Index(Index { id: new_id(), keys: BTreeSet::new() })
}
fn pending_path(&self) -> PathBuf {
self.path.with_extension("json.migrating")
}
/// The id of a move that has not finished (see [`SecretFile::migrate`]).
fn pending_id(&self) -> Option<String> {
let id = std::fs::read_to_string(self.pending_path()).ok()?;
Some(id.trim().to_owned()).filter(|id| !id.is_empty())
}
/// Moves an older version's secrets to the credential store, key by key; a secret
/// the store refuses for good (too long, say) is forgotten, and the user is asked
/// for it again. The old file is replaced only when everything else was moved.
/// Until then the id waits in `<file>.migrating`, so a retry after a failure or a
/// crash overwrites the same credentials instead of copying them again.
fn migrate(&self, old: BTreeMap<String, String>) -> Contents {
if !self.vault.available() {
return Contents::Plaintext(old, "no credential store".into());
}
let pending = self.pending_path();
let id = match self.pending_id() {
Some(id) => id,
None => {
let id = new_id();
if let Err(e) = std::fs::write(&pending, &id) {
return Contents::Plaintext(old, format!("{}: {e}", pending.display()));
}
id
}
};
let mut index = Index { id, keys: BTreeSet::new() };
for (key, secret) in &old {
match self.vault.set(&service(&index.id), key, secret) {
Ok(()) => {
index.keys.insert(key.clone());
}
Err(e @ (StoreError::TooLong(..) | StoreError::Invalid(..))) => {
tracing::warn!("saved passwords: {key} does not fit the credential store and is forgotten: {e}");
}
Err(e) => {
tracing::warn!("saved passwords: could not move {key} to the credential store: {e}");
return Contents::Plaintext(old, e.to_string());
}
}
}
if let Err(e) = self.write(&index) {
return Contents::Plaintext(old, e.message);
}
let _ = std::fs::remove_file(&pending);
Contents::Index(index)
}
fn write_logged(&self, contents: &impl Serialize) {
if let Err(e) = self.write(contents) {
tracing::warn!("{}", e.message);
}
}
fn write(&self, contents: &impl Serialize) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
@@ -65,18 +293,258 @@ impl SecretFile {
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
let bytes = serde_json::to_vec_pretty(contents).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
fn unavailable(why: &str) -> IdeError {
IdeError::unavailable(format!("saved passwords: {why}"))
}
/// Helpers for tests of crates that save secrets (feature `testing`).
#[cfg(any(test, feature = "testing"))]
pub mod testing {
use std::path::{Path, PathBuf};
/// Makes an in-memory credential store the default, so that the test never
/// touches the user's keychain. Call it before the first secret is saved.
pub fn use_memory_store() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| keyring_core::set_default_store(keyring_core::sample::Store::new().expect("memory store")));
}
/// Every file under `dir` whose bytes contain `needle`.
pub fn files_containing(dir: &Path, needle: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![dir.to_path_buf()];
while let Some(d) = pending.pop() {
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if std::fs::read(&path).is_ok_and(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes())) {
found.push(path);
}
}
}
found
}
}
#[cfg(test)]
mod tests {
use super::testing::{files_containing, use_memory_store};
use super::*;
use std::collections::HashMap;
/// A credential store that can be absent, or fail on chosen keys.
#[derive(Default)]
struct FakeVault {
absent: bool,
entries: Mutex<HashMap<(String, String), String>>,
failing: Mutex<HashMap<String, fn() -> StoreError>>,
}
impl FakeVault {
fn fail(&self, key: &str, error: fn() -> StoreError) {
self.failing.lock().insert(key.to_owned(), error);
}
fn heal(&self) {
self.failing.lock().clear();
}
fn check(&self, key: &str) -> StoreResult<()> {
if self.absent {
return Err(StoreError::NoDefaultStore);
}
self.failing.lock().get(key).map_or(Ok(()), |e| Err(e()))
}
fn secrets(&self) -> Vec<String> {
let mut all: Vec<_> = self.entries.lock().values().cloned().collect();
all.sort();
all
}
}
impl Vault for FakeVault {
fn available(&self) -> bool {
!self.absent
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
self.check(key)?;
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
self.check(key)?;
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
Ok(())
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
self.check(key)?;
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
}
}
fn platform() -> StoreError {
StoreError::PlatformFailure("locked".into())
}
fn with_fake(path: &std::path::Path, vault: &Arc<FakeVault>) -> SecretFile {
SecretFile::with_vault(path.to_path_buf(), |v| serde_json::from_value(v).ok(), vault.clone())
}
const OLD: &str = r#"{"https://a.example": "me\nold-a", "https://b.example": "me\nold-b"}"#;
#[test]
fn saved_secrets_never_reach_disk() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
SecretFile::new(path.clone()).set("a", "hunter2-ssh").unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-ssh"), Vec::<PathBuf>::new());
let reopened = SecretFile::new(path);
assert_eq!(reopened.get("a").as_deref(), Some("hunter2-ssh"));
assert_eq!(reopened.keys(), ["a"]);
}
#[test]
fn moves_plaintext_files_of_older_versions_off_disk() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, r#"{"https://example.com": "me\nold-pw"}"#).unwrap();
let file = SecretFile::new(path.clone());
assert_eq!(file.keys(), ["https://example.com"]);
assert_eq!(files_containing(tmp.path(), "old-pw"), Vec::<PathBuf>::new());
assert_eq!(SecretFile::new(path).get("https://example.com").as_deref(), Some("me\nold-pw"));
}
#[test]
fn a_failed_move_keeps_the_old_file_and_the_retry_converges() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", platform);
let file = with_fake(&path, &vault);
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), OLD, "the old file is untouched");
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("locked"));
vault.heal();
assert_eq!(file.keys(), ["https://a.example", "https://b.example"]);
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
assert_eq!(vault.secrets(), ["me\nold-a", "me\nold-b"], "the retry overwrote, not copied");
assert!(!path.with_extension("json.migrating").exists());
assert_eq!(with_fake(&path, &vault).get("https://b.example").as_deref(), Some("me\nold-b"));
}
#[test]
fn forgetting_during_a_pending_move_forgets_the_copy_too() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", platform);
let file = with_fake(&path, &vault);
file.delete("https://a.example");
vault.heal();
assert_eq!(file.keys(), ["https://b.example"]);
assert_eq!(vault.secrets(), ["me\nold-b"]);
}
#[test]
fn a_secret_the_store_refuses_for_good_is_forgotten_and_does_not_block_saving() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault::default());
vault.fail("https://b.example", || StoreError::TooLong("password".into(), 2560));
let file = with_fake(&path, &vault);
assert_eq!(file.keys(), ["https://a.example"]);
assert_eq!(files_containing(tmp.path(), "old-b"), Vec::<PathBuf>::new());
file.set("https://c.example", "me\nnew").unwrap();
assert_eq!(file.get("https://c.example").as_deref(), Some("me\nnew"));
}
#[test]
fn without_a_credential_store_the_old_file_is_read_and_nothing_new_is_saved() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, OLD).unwrap();
let vault = Arc::new(FakeVault { absent: true, ..Default::default() });
let file = with_fake(&path, &vault);
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("no credential store"));
assert_eq!(files_containing(tmp.path(), "me\nnew"), Vec::<PathBuf>::new());
assert!(!path.with_extension("json.migrating").exists());
file.delete("https://a.example");
assert_eq!(file.keys(), ["https://b.example"]);
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
}
#[test]
fn forgets_only_what_the_store_forgot() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
file.set("a", "pw").unwrap();
vault.fail("a", platform);
file.delete("a");
assert_eq!(file.keys(), ["a"], "still listed, so that forgetting can be retried");
vault.heal();
file.delete("a");
assert_eq!(file.keys(), Vec::<String>::new());
assert_eq!(vault.secrets(), Vec::<String>::new());
}
#[test]
fn a_secret_removed_outside_the_app_is_no_longer_listed() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
file.set("a", "pw").unwrap();
vault.entries.lock().clear();
assert_eq!(file.get("a"), None);
assert_eq!(file.keys(), Vec::<String>::new());
}
#[test]
fn a_failed_index_write_takes_the_secret_back_out_of_the_store() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
std::fs::create_dir(&path).unwrap();
let vault = Arc::new(FakeVault::default());
assert!(with_fake(&path, &vault).set("a", "pw").is_err());
assert_eq!(vault.secrets(), Vec::<String>::new());
}
#[test]
fn an_unreadable_file_is_set_aside_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("ssh-passwords.json");
std::fs::write(&path, "{ not json").unwrap();
let vault = Arc::new(FakeVault::default());
let file = with_fake(&path, &vault);
assert_eq!(file.keys(), Vec::<String>::new());
file.set("a", "pw").unwrap();
std::fs::write(&path, "{ not json either").unwrap();
assert_eq!(file.keys(), Vec::<String>::new());
assert_eq!(files_containing(tmp.path(), "{ not json").len(), 2, "both kept, neither overwritten");
}
#[test]
fn stores_and_forgets_secrets() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
file.set("a", "pw").unwrap();
+17 -7
View File
@@ -267,19 +267,29 @@ impl Session {
}),
)
.await?;
let failed = |code: Option<u32>| {
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))
};
// A command that exits early (no gzip, a folder it cannot write) stops reading: a
// write would wait for channel window forever, so each one races the exit.
let mut exit = std::pin::pin!(process.wait());
let mut sent = 0;
for chunk in packed.chunks(256 * 1024) {
process.write(chunk).await?;
tokio::select! {
written = process.write(chunk) => written?,
code = &mut exit => return Err(failed(code)),
}
sent += chunk.len();
progress(sent as f64 / packed.len().max(1) as f64);
}
process.eof().await?;
match process.wait().await {
tokio::select! {
closed = process.eof() => closed?,
code = &mut exit => return Err(failed(code)),
}
match exit.await {
Some(0) => Ok(()),
code => {
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label)))
}
code => Err(failed(code)),
}
}
@@ -0,0 +1,44 @@
//! Saved passwords against the real OS credential store (Windows Credential
//! Manager, macOS Keychain, Secret Service).
use ide_ssh::secrets::SecretFile;
/// Forgets every key of the file, also when the test panics.
struct Forget<'a>(&'a SecretFile);
impl Drop for Forget<'_> {
fn drop(&mut self) {
for key in self.0.keys() {
self.0.delete(&key);
}
}
}
#[test]
#[ignore = "uses the OS credential store"]
fn saves_moves_and_forgets_in_the_os_store() {
assert!(keyring::Entry::store_status().is_ok(), "no OS credential store");
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("git-credentials.json");
std::fs::write(&path, r#"{"https://old.example": "me\nold-os-pw"}"#).unwrap();
let file = SecretFile::new(path.clone());
let forget = Forget(&file);
file.set("https://new.example", "me\nnew-os-pw").unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains("old-os-pw") && !on_disk.contains("new-os-pw"), "{on_disk}");
let reopened = SecretFile::new(path);
assert_eq!(reopened.get("https://old.example").as_deref(), Some("me\nold-os-pw"));
assert_eq!(reopened.get("https://new.example").as_deref(), Some("me\nnew-os-pw"));
#[cfg(windows)]
{
let index: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
let service = format!("ai.l0ng.openidea/{}", index["id"].as_str().unwrap());
let listed = std::process::Command::new("cmdkey").arg("/list").output().unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert_eq!(listed.matches(service.as_str()).count(), 2, "{listed}");
}
drop(forget);
assert_eq!(reopened.keys(), Vec::<String>::new());
assert_eq!(reopened.get("https://old.example"), None);
}
+23
View File
@@ -269,3 +269,26 @@ async fn runs_commands_and_streams_processes() {
assert_eq!(process.wait().await, Some(3));
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
// Incompressible and larger than the channel window: the command exits (status 3, `oops`)
// without reading it, as when the host has no gzip or the folder is not writable.
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
let data: Vec<u8> = (0..8 << 20)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state as u8
})
.collect();
let put = session.put("/home/dev/.openidea/server/openidea-server", &data);
let err = tokio::time::timeout(std::time::Duration::from_secs(20), put)
.await
.expect("the upload must end when the command exits")
.unwrap_err();
assert!(err.message.contains("oops"), "{}", err.message);
}
+1
View File
@@ -12,6 +12,7 @@ ide-core.workspace = true
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
ropey = { version = "1.6", default-features = false, features = ["simd"] }
serde.workspace = true
similar = { version = "2.7", default-features = false }
specta.workspace = true
[dev-dependencies]
+4
View File
@@ -73,6 +73,10 @@ fn bench(c: &mut Criterion) {
BatchSize::SmallInput,
)
});
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
let every_line = text.replace("compute", "calculate");
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
c.bench_function("text/offset_to_pos_round_trip", |b| {
let mut rng = Lcg(3);
let len = base.len_utf16();
+124 -31
View File
@@ -14,6 +14,7 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
use serde::{Deserialize, Serialize};
use specta::Type;
use std::borrow::Cow;
use std::time::{Duration, Instant};
pub use ropey;
@@ -66,6 +67,36 @@ impl LineEnding {
}
}
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
const DIFF_BUDGET: Duration = Duration::from_millis(50);
type LineRange = std::ops::Range<usize>;
/// Byte lengths of the common prefix and suffix; they never overlap.
fn common_ends(a: &str, b: &str) -> (usize, usize) {
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
};
let prefix = shared(&mut a.chars(), &mut b.chars());
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
(prefix, suffix)
}
/// Byte offset of each line, then the total length.
fn line_starts(lines: &[&str]) -> Vec<usize> {
let mut at = 0;
let mut starts = vec![0];
starts.extend(lines.iter().map(|line| {
at += line.len();
at
}));
starts
}
fn utf16_len(text: &str) -> u32 {
text.chars().map(|c| c.len_utf16() as u32).sum()
}
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
if !text.contains('\r') {
@@ -334,42 +365,63 @@ impl Document {
cs
}
/// The minimal single-change (common prefix/suffix) change set turning the
/// current text into `text` (after line-ending normalization). Empty when equal.
/// The change set turning the current text into `text` (after line-ending
/// normalization), one change per changed region. Empty when equal. A single change
/// spanning two distant edits would drag the editor's caret and view to the first one.
pub fn diff(&self, text: &str) -> ChangeSet {
self.diff_until(text, Instant::now() + DIFF_BUDGET)
}
/// Past `deadline` the regions get coarser, never wrong.
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
let new = normalize_line_endings(text);
let old_chars = self.rope.len_chars();
let old = self.rope.to_string();
let (prefix, suffix) = common_ends(&old, &new);
let old_rest = &old[prefix..old.len() - suffix];
let new_rest = &new[prefix..new.len() - suffix];
let mut prefix_chars = 0usize;
let mut prefix_bytes = 0usize;
for (a, b) in self.rope.chars().zip(new.chars()) {
if a != b {
break;
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
let old_starts = line_starts(&old_lines);
let new_starts = line_starts(&new_lines);
let mut changes = Vec::new();
let mut utf16_at = utf16_len(&old[..prefix]);
let mut old_at = 0;
let mut region: Option<(LineRange, LineRange)> = None;
let ops =
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
let mut flush = |region: Option<(LineRange, LineRange)>| {
let Some((old_range, new_range)) = region else { return };
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
let (head, tail) = common_ends(region_old, region_new);
let start = old_starts[old_range.start] + head;
let end = old_starts[old_range.end] - tail;
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
let to = from + utf16_len(&old_rest[start..end]);
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
utf16_at = to;
old_at = end;
};
// Positions come from the lengths: `similar` can report a stale start for the empty
// side of an op (an insert after an equal run still names the run's first old line).
let (mut old_line, mut new_line) = (0, 0);
for op in ops {
let old_end = old_line + op.old_range().len();
let new_end = new_line + op.new_range().len();
if op.tag() == similar::DiffTag::Equal {
flush(region.take());
} else {
region = Some(match region.take() {
Some((o, n)) => (o.start..old_end, n.start..new_end),
None => (old_line..old_end, new_line..new_end),
});
}
prefix_chars += 1;
prefix_bytes += b.len_utf8();
(old_line, new_line) = (old_end, new_end);
}
let new_rest = &new[prefix_bytes..];
let new_rest_chars = new_rest.chars().count();
if prefix_chars == old_chars && new_rest_chars == 0 {
return ChangeSet::default();
}
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
let mut suffix_chars = 0usize;
let mut suffix_bytes = 0usize;
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
if a != b {
break;
}
suffix_chars += 1;
suffix_bytes += b.len_utf8();
}
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
ChangeSet { changes: vec![TextChange { from, to, insert }] }
flush(region);
ChangeSet { changes }
}
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
@@ -606,4 +658,45 @@ mod tests {
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
assert_eq!(doc.text(), "😀x😀");
}
fn numbered_lines(n: usize) -> String {
(0..n).map(|i| format!("line {i}\n")).collect()
}
#[test]
fn diff_keeps_distant_edits_apart() {
let old = numbered_lines(400);
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
let set = Document::new(&old).diff(&new);
assert_eq!(
set.changes,
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
);
}
#[test]
fn diff_handles_added_and_removed_lines() {
let old = "a\nb\nc\nd\ne\nf\n";
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
}
#[test]
fn diff_counts_utf16_past_astral_text() {
let old = "😀 one\nsame\n😀 two\n";
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
}
#[test]
fn diff_past_its_deadline_is_coarser_but_still_correct() {
let old = numbered_lines(5000);
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
let doc = Document::new(&old);
let set = doc.diff_until(&new, std::time::Instant::now());
let mut replayed = Document::new(&old);
replayed.apply(0, &set).unwrap();
assert_eq!(replayed.text(), new);
}
}
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
+1 -1
View File
@@ -176,7 +176,7 @@ proptest! {
let set = doc.replace_all(&b);
prop_assert_eq!(doc.text(), b.clone());
prop_assert_eq!(naive_apply(&a, &set), b.clone());
prop_assert!(set.changes.len() <= 1);
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
prop_assert_eq!(set.changes.is_empty(), a == b);
}
+36 -1
View File
@@ -8,6 +8,12 @@
//!
//! Language servers find managed tools once [`extend_process_path`] has put
//! [`tools_bin_dirs`] on the process `PATH` at startup.
//!
//! GitHub API downloads prefer `GITHUB_TOKEN`, then `GH_TOKEN`, then the active
//! `github.com` account from `gh auth token --hostname github.com`. The CLI is
//! optional; absent credentials retain anonymous downloads. Tokens are passed
//! to curl over stdin, never stored in tool manifests or command arguments, and
//! are not sent to other download origins. CLI lookup is cancellable and bounded.
pub mod archive;
mod bundled;
@@ -389,6 +395,11 @@ pub fn parse_version(text: &str) -> Option<String> {
None
}
/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`).
fn is_app_execution_alias(path: &Path) -> bool {
path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps"))
}
/// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable.
fn is_bundle(command: &str) -> bool {
command.ends_with(".jar")
@@ -485,8 +496,32 @@ impl Tools {
cmd
}
/// The interpreter that installs and probes Python tools. Windows installs `python.exe`
/// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution
/// Aliases that only open the Store unless the Store's Python is installed, so they count
/// only when they run.
fn python(&self) -> Option<PathBuf> {
self.which_any(&["python3", "python"])
if self.platform.os != Os::Windows {
return self.which_any(&["python3", "python"]);
}
["python", "py", "python3"]
.into_iter()
.flat_map(|command| {
std::env::split_paths(&self.system_path).filter_map(move |dir| {
if dir.as_os_str().is_empty() {
return None;
}
let search = std::env::join_paths([dir]).ok()?;
ide_core::tool_path::find_command(command, &search, &self.root)
})
})
.find(|p| {
!is_app_execution_alias(p) || {
let mut c = self.python_command(p);
c.arg("--version");
run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok)
}
})
}
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
+346 -17
View File
@@ -6,9 +6,9 @@ use ide_core::error::ErrorKind;
use ide_core::output_encoding::decode_tool_output;
use ide_core::process::{kill_tree, own_group};
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::{BufRead, BufReader, Read};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
@@ -144,6 +144,99 @@ fn strip_ansi(s: &str) -> String {
out
}
const MAX_GITHUB_TOKEN: usize = 4096;
fn valid_github_token(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
.then(|| value.to_owned())
}
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
}
/// Ask gh for the active github.com account on each request, so switching or
/// logging out takes effect without persisting a second copy of the credential.
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = Command::new(gh);
own_group(ide_core::process::prepare(&mut cmd));
cmd.args(["auth", "token", "--hostname", "github.com"])
.env("GH_PROMPT_DISABLED", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
let stdout = child.stdout.take().expect("piped");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
let _ = tx.send(bytes);
});
let started = Instant::now();
let mut status = None;
let mut output = None;
let bytes = loop {
if cancel.is_cancelled() || started.elapsed() >= timeout {
kill_tree(&mut child);
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
}
if status.is_none() {
match child.try_wait() {
Ok(value) => status = value,
Err(_) => {
kill_tree(&mut child);
return Ok(None);
}
}
}
if let Some(status) = status {
if !status.success() {
kill_tree(&mut child);
return Ok(None);
}
if let Some(bytes) = output.take() {
break bytes;
}
}
match rx.recv_timeout(Duration::from_millis(15)) {
Ok(bytes) => output = Some(bytes),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
}
};
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
return Ok(None);
}
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
}
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
if let Some(token) = token {
let result = child
.stdin
.take()
.expect("authenticated request has piped stdin")
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
if let Err(error) = result {
kill_tree(child);
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
}
}
Ok(())
}
fn redact_auth(message: &str, token: Option<&str>) -> String {
match token {
Some(token) => message.replace(token, "[redacted]"),
None => message.to_owned(),
}
}
/// Whether `curl` checks certificate revocation with Schannel and takes
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
@@ -174,6 +267,8 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
/// mainstream Linux), so the IDE carries no TLS stack of its own.
pub struct Http {
curl: std::path::PathBuf,
gh: Option<std::path::PathBuf>,
explicit_github_token: Option<String>,
https_only: bool,
revoke_best_effort: bool,
}
@@ -182,8 +277,10 @@ impl Http {
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
let curl = which::which_in("curl", Some(search_path), ".")
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
let gh = which::which_in("gh", Some(search_path), ".").ok();
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
Ok(Self { curl, https_only: false, revoke_best_effort })
Ok(Self { curl, gh, explicit_github_token, https_only: false, revoke_best_effort })
}
/// Refuses every protocol but HTTPS, redirects included.
@@ -192,8 +289,28 @@ impl Http {
self
}
fn base(&self, url: &str) -> Command {
fn base(&self, url: &str, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let github = url.starts_with("https://api.github.com/");
let token = if github {
match &self.explicit_github_token {
Some(token) => Some(token.clone()),
None => match &self.gh {
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
None => None,
},
}
} else {
None
};
let mut cmd = Command::new(&self.curl);
// A user curlrc must not enable verbose token logging or forwarding
// Authorization across origins on authenticated API requests.
if token.is_some() {
cmd.arg("-q");
}
own_group(ide_core::process::prepare(&mut cmd));
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
@@ -204,13 +321,14 @@ impl Http {
if self.revoke_best_effort {
cmd.arg("--ssl-revoke-best-effort");
}
if url.starts_with("https://api.github.com/")
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
&& !token.is_empty()
{
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
cmd.stdin(Stdio::null());
if token.is_some() {
// Keep credentials out of process arguments, inherited environment,
// and files. curl strips Authorization on cross-origin redirects.
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
}
cmd
Ok((cmd, token))
}
/// GETs `url` as text.
@@ -235,15 +353,16 @@ impl Http {
empty_on_404: bool,
cancel: &CancellationToken,
) -> IdeResult<String> {
let mut cmd = self.base(url);
let (mut cmd, token) = self.base(url, cancel)?;
if empty_on_404 {
cmd.args(["--write-out", "\n%{http_code}"]);
}
if let Some(max) = max_bytes {
cmd.arg("--max-filesize").arg(max.to_string());
}
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.arg(url).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
send_auth(&mut child, token.as_deref())?;
let stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
// Past the limit the pipe closes, which stops curl.
@@ -280,7 +399,10 @@ impl Http {
let mut err = Vec::new();
let _ = stderr.read_to_end(&mut err);
let err = decode_tool_output(&err);
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
return Err(IdeError::new(
ErrorKind::Io,
format!("GET {url} failed: {}", redact_auth(err.trim(), token.as_deref())),
));
}
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
}
@@ -294,9 +416,10 @@ impl Http {
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let mut cmd = self.base(url);
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
let (mut cmd, token) = self.base(url, cancel)?;
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
send_auth(&mut child, token.as_deref())?;
let mut stderr = child.stderr.take().expect("piped");
let err_t = std::thread::spawn(move || {
let mut s = Vec::new();
@@ -327,7 +450,10 @@ impl Http {
if !status.success() {
return Err(IdeError::new(
ErrorKind::Io,
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
format!(
"download of {url} failed: {}",
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
),
));
}
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
@@ -490,7 +616,13 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let curl = tmp.path().join("curl");
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
let http = Http {
curl: curl.clone(),
gh: None,
explicit_github_token: None,
https_only: false,
revoke_best_effort: false,
};
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
{
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
@@ -595,3 +727,200 @@ mod input_tests {
);
}
}
#[cfg(test)]
mod github_auth_tests {
use super::*;
#[test]
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
assert_eq!(
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
Some("second")
);
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
assert!(valid_github_token(bad.into()).is_none());
}
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
}
#[cfg(unix)]
mod unix {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
fn quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
}
struct Fixture {
dir: tempfile::TempDir,
http: Http,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let curl = dir.path().join("curl");
let gh = dir.path().join("gh");
let fixture = Self {
dir,
http: Http {
curl,
gh: Some(gh),
explicit_github_token: None,
https_only: false,
revoke_best_effort: false,
},
};
fixture.script(
"gh",
&format!(
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
quote(&fixture.path("gh-args")),
quote(&fixture.path("token"))
),
);
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
fixture.curl("printf '{\"ok\":true}'");
fixture
}
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn script(&self, name: &str, body: &str) {
let path = self.path(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
fn curl(&self, body: &str) {
self.script(
"curl",
&format!(
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
quote(&self.path("curl-args")),
quote(&self.path("headers"))
),
);
}
fn get(&self, url: &str) -> IdeResult<String> {
self.http.get_text(url, &CancellationToken::new())
}
}
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
#[test]
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
let f = Fixture::new();
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
let args = fs::read_to_string(f.path("curl-args")).unwrap();
assert!(!args.contains("gho_fake"));
assert!(args.starts_with("-q\n"));
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
assert!(!args.contains("--location-trusted"));
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
}
#[test]
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
let mut f = Fixture::new();
f.http.explicit_github_token = Some("explicit_fake".into());
f.get(API).unwrap();
assert!(!f.path("gh-args").exists());
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
f.http.explicit_github_token = None;
for url in [
"https://example.com/file",
"http://api.github.com/repos/x",
"https://api.github.com.evil.example/file",
"https://[email protected]/file",
"https://github.com/example/project/releases/download/v1/file",
] {
f.get(url).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
assert!(!f.path("gh-args").exists());
}
}
#[test]
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
let mut f = Fixture::new();
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
f.script("gh", body);
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
}
f.http.gh = None;
f.get(API).unwrap();
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
}
#[test]
fn download_auth_and_errors_do_not_expose_the_token() {
let f = Fixture::new();
let dest = f.path("download");
f.curl(&format!("printf downloaded > {}", quote(&dest)));
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
let error = f.get(API).unwrap_err().to_string();
assert!(error.contains("[redacted]"));
assert!(!error.contains("gho_fake_first"));
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
assert!(!error.contains("gho_fake_first"));
}
#[test]
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
let f = Fixture::new();
// The parent exits but a child keeps stdout open: joining a reader
// without checking the deadline would hang here.
f.script("gh", "/bin/sleep 5 &\nexit 0");
let started = Instant::now();
assert!(
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
.unwrap()
.is_none()
);
assert!(started.elapsed() < Duration::from_secs(3));
f.script("gh", "/bin/sleep 5");
let cancel = CancellationToken::new();
let trigger = cancel.clone();
let thread = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
trigger.cancel();
});
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
thread.join().unwrap();
assert!(!f.path("curl-args").exists());
}
#[test]
#[ignore = "requires an active gh github.com login and network access"]
fn live_github_release_uses_authenticated_quota() {
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
assert!(http.gh.is_some());
let cancel = CancellationToken::new();
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
let body = http
.get_text_limited(
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
Some(1024 * 1024),
&cancel,
)
.unwrap();
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(response["tag_name"].is_string());
}
}
}
+34
View File
@@ -37,6 +37,12 @@ impl Env {
fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
archive::make_executable(&p).unwrap();
}
#[cfg(windows)]
fn script(&self, dir: &Path, name: &str, body: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap();
}
}
fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
@@ -700,6 +706,34 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
assert!(!env.root.join("rust-analyzer").exists());
}
/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count
/// when the Store's Python behind them runs.
#[test]
fn windows_python_skips_store_aliases_that_do_not_run() {
let env = Env::new();
let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps");
env.script(&aliases, "python", "exit 9009");
env.script(&aliases, "python3", "exit 9009");
env.script(&env.sys, "py", "echo Python 3.12.0");
let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 };
let found = || {
let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap();
Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| {
let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy())
})
};
assert_eq!(found().as_deref(), Some("sysbin/py"));
fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap();
assert_eq!(found(), None, "only broken aliases are available");
env.script(&env.sys, "python3", "echo Python 3.12.0");
assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback");
env.script(&env.sys, "python", "echo Python 3.12.0");
assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter");
env.script(&aliases, "python", "echo Python 3.13.0");
assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs");
}
#[cfg(unix)]
#[test]
fn python_probes_run_in_an_empty_private_directory() {
+2
View File
@@ -15,6 +15,8 @@ serde_json.workspace = true
specta.workspace = true
gix = { workspace = true, features = ["sha1", "sha256", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] }
tracing.workspace = true
redb.workspace = true
blake3.workspace = true
tempfile.workspace = true
which = "8"
regex-lite = "0.1"
+192
View File
@@ -0,0 +1,192 @@
//! Bek branch partitioning and bottom-up block merge.
//! Adapted from JetBrains intellij-community BekBranchCreator/BekBranch/Merger,
//! Copyright 2000-2026 JetBrains s.r.o. and contributors, Apache-2.0.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashSet};
pub(crate) fn sort(parents: &[Vec<usize>], times: &[i64], heads: &[usize]) -> Vec<usize> {
let n = parents.len();
let mut children = vec![Vec::new(); n];
for (i, ps) in parents.iter().enumerate() {
for &p in ps {
children[p].push(i);
}
}
let mut layout = vec![0usize; n];
let mut important = Vec::new();
let mut next_layout = 1;
for &head in heads {
if layout[head] != 0 {
continue;
}
important.push(head);
let mut stack = vec![head];
while let Some(&node) = stack.last() {
let first = layout[node] == 0;
if first {
layout[node] = next_layout;
}
if let Some(&p) = parents[node].iter().find(|&&p| layout[p] == 0) {
stack.push(p);
} else {
if first {
next_layout += 1;
}
stack.pop();
}
}
}
let mut done = vec![false; n];
let mut restrictions = vec![HashSet::new(); n];
let mut releases = vec![Vec::new(); n];
let mut branches = Vec::new();
for head in important {
if done[head] {
continue;
}
let start = layout[head];
done[head] = true;
let mut branch = vec![head];
let mut stack = vec![head];
while let Some(&node) = stack.last() {
let mut next = None;
for &p in parents[node].iter().rev() {
if done[p] {
if layout[p] < start && restrictions[node].insert(p) {
releases[p].push(node);
}
} else if layout[node] <= layout[p] && !children[p].iter().any(|&c| !done[c] && layout[c] <= layout[p])
{
next = Some(p);
break;
}
}
if let Some(p) = next {
done[p] = true;
branch.push(p);
stack.push(p);
} else {
stack.pop();
}
}
branches.push(branch);
}
let mut owner = vec![0usize; n];
for (b, nodes) in branches.iter().enumerate() {
for &node in nodes {
owner[node] = b;
}
}
let mut remaining: Vec<_> = branches.iter().map(Vec::len).collect();
let mut prepared = vec![None; branches.len()];
let mut queue = BinaryHeap::new();
let prepare = |b: usize,
remaining: &[usize],
restrictions: &[HashSet<usize>],
prepared: &mut [Option<usize>],
queue: &mut BinaryHeap<Reverse<(i64, usize)>>| {
let end = remaining[b];
if end == 0 || prepared[b].is_some() || !restrictions[branches[b][end - 1]].is_empty() {
return;
}
let mut start = end - 1;
while start > 0 {
let up = branches[b][start - 1];
let down = branches[b][start];
if !restrictions[up].is_empty() {
break;
}
let connects = parents[up].contains(&down);
if !(parents[up].len() > 1 && connects) {
if !connects {
break;
}
let delta = times[up].abs_diff(times[down]);
if delta > 3 * 24 * 3600 || (start + 20 < end && delta > 4 * 3600) {
break;
}
}
start -= 1;
}
prepared[b] = Some(start);
queue.push(Reverse((times[branches[b][start]], b)));
};
for b in 0..branches.len() {
prepare(b, &remaining, &restrictions, &mut prepared, &mut queue);
}
let mut result = vec![0; n];
let mut offset = n;
while let Some(Reverse((_, b))) = queue.pop() {
let start = prepared[b].take().unwrap();
let part = &branches[b][start..remaining[b]];
offset -= part.len();
result[offset..offset + part.len()].copy_from_slice(part);
remaining[b] = start;
let mut released = HashSet::from([b]);
for &node in part {
for &up in &releases[node] {
restrictions[up].remove(&node);
let branch = owner[up];
if restrictions[up].is_empty() && remaining[branch] > 0 && branches[branch][remaining[branch] - 1] == up
{
released.insert(branch);
}
}
}
for branch in released {
prepare(branch, &remaining, &restrictions, &mut prepared, &mut queue);
}
}
debug_assert_eq!(offset, 0, "Bek must place every node");
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn branch_blocks_preserve_every_edge_under_clock_skew_and_interleaved_refs() {
let mut seed = 17u64;
let mut random = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
seed as usize
};
for _ in 0..200 {
let n = 70;
let parents: Vec<Vec<usize>> = (0..n)
.map(|i| {
let mut ps = Vec::new();
if i + 1 < n {
for _ in 0..(random() % 3 + 1) {
let p = i + 1 + random() % (n - i - 1);
if !ps.contains(&p) {
ps.push(p);
}
}
}
ps
})
.collect();
let times: Vec<_> = (0..n).map(|_| (random() % 1_000_000) as i64).collect();
let mut children = vec![0; n];
for ps in &parents {
for &p in ps {
children[p] += 1;
}
}
let mut heads: Vec<_> = (0..n).filter(|&i| children[i] == 0 || i % 11 == 0).collect();
heads.sort_by_cached_key(|_| random());
let result = sort(&parents, &times, &heads);
assert_eq!(result.iter().copied().collect::<HashSet<_>>().len(), n);
let mut positions = vec![0; n];
for (i, &node) in result.iter().enumerate() {
positions[node] = i;
}
for (node, ps) in parents.iter().enumerate() {
for &p in ps {
assert!(positions[node] < positions[p]);
}
}
}
}
}
File diff suppressed because it is too large. Load diff
+254 -3
View File
@@ -112,22 +112,64 @@ pub struct Commit {
pub id: String,
pub short_id: String,
pub parents: Vec<String>,
/// Connections in the filtered graph; never use these as revision/diff parents.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_parents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_dashed: Vec<bool>,
/// Repository-relative filename at this revision in rename-following history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_position: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_total: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_boundary_parents: Vec<String>,
/// Reachability from HEAD, computed independently of the visible filters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_current_branch: Option<bool>,
pub author: String,
pub email: String,
#[serde(default)]
pub committer: String,
#[serde(default)]
pub committer_email: String,
pub time_ms: f64,
pub summary: String,
pub message: String,
#[serde(default)]
pub message_truncated: bool,
pub refs: Vec<String>,
/// Committer time (Log › View Options › Commit Timestamp).
#[serde(default)]
pub commit_time_ms: f64,
/// Per parent, under a user or text filter: the link passes commits the filter hides
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
/// Legacy alias of `graph_dashed`, indexed by displayed parents.
#[serde(default)]
#[specta(optional)]
pub dashed: Vec<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogDetails {
pub message: String,
pub containing_branches: Vec<String>,
/// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked.
pub signature: String,
pub signer: String,
pub key: String,
pub fingerprint: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogIndexProgress {
pub indexed: u32,
pub total: u32,
pub running: bool,
}
/// Order of the Git Log (Graph Options › Sort).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -137,13 +179,29 @@ pub enum LogOrder {
Topo,
/// `--date-order`: commit time, but never a parent before its children.
Date,
// Topological grouping with incoming merge parents visited before the main line.
Standard,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogQuery {
#[serde(default)]
pub speed_search: Option<String>,
#[serde(default)]
pub search_fields: Vec<String>,
/// Jump directly to an indexed window containing this commit (50 rows of context).
#[serde(default)]
pub seek: Option<String>,
#[serde(default)]
pub retain_until: Option<String>,
pub branch: Option<String>,
pub path: Option<ide_core::IpcPath>,
#[serde(default)]
pub paths: Vec<ide_core::IpcPath>,
/// File history follows renames; structure filters retain fixed paths.
#[serde(default)]
pub follow: bool,
pub author: Option<String>,
pub text: Option<String>,
pub skip: u32,
@@ -152,6 +210,9 @@ pub struct LogQuery {
/// not the left). Takes precedence over `branch`.
#[serde(default)]
pub range: Option<String>,
/// Additional ranges unioned with each other and with selected branches.
#[serde(default)]
pub ranges: Vec<String>,
/// `text` is a regular expression (Log filter `.*`).
#[serde(default)]
pub text_regex: bool,
@@ -165,6 +226,8 @@ pub struct LogQuery {
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub authors_exact: bool,
#[serde(default)]
pub order: LogOrder,
/// Follow only the first parent of merges (`--first-parent`).
#[serde(default)]
@@ -179,6 +242,13 @@ pub struct LogQuery {
pub since_ms: Option<f64>,
#[serde(default)]
pub until_ms: Option<f64>,
#[serde(default)]
pub request: Option<ide_core::RequestId>,
#[serde(default)]
pub summary_only: bool,
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
#[serde(default)]
pub stop_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -487,6 +557,7 @@ pub struct ChangelistState {
pub files: std::collections::BTreeMap<String, String>,
}
mod bek;
mod blame;
mod block_history;
pub mod changelists;
@@ -496,6 +567,7 @@ mod diff;
mod history;
mod ignore;
mod index_writes;
mod log_index;
mod manage;
mod ops;
mod patch;
@@ -599,6 +671,9 @@ pub struct Repo {
git_dir: PathBuf,
inner: std::sync::Mutex<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
log_index: std::sync::Mutex<log_index::LogIndex>,
index_progress: std::sync::Mutex<LogIndexProgress>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
impl Repo {
@@ -738,7 +813,183 @@ impl Repo {
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query)
history::log(self, query, &CancellationToken::new())
}
pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
history::log(self, query, cancel)
}
/// Enables the disposable, content-addressed log cache outside the worktree.
pub fn enable_log_index(&self, cache_dir: &Path) {
let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key));
}
/// Reference names and peeled commit ids, including tags and detached HEAD.
pub fn log_refs(&self) -> IdeResult<std::collections::BTreeMap<String, String>> {
let repo = self.gix()?;
Ok(history::decorations(&repo)?
.into_iter()
.flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string())))
.collect())
}
pub fn log_users(&self) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?;
Ok(out
.stdout_str()
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect())
}
pub fn log_index_progress(&self) -> LogIndexProgress {
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
{
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
if progress.running {
return Ok(progress.clone());
}
progress.running = true;
}
let result = (|| {
let repo = self.gix()?;
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
let ids: Vec<_> = out
.stdout_str()
.lines()
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
.collect::<IdeResult<_>>()?;
{
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
p.total = ids.len() as u32;
p.indexed = 0;
}
for (i, id) in ids.iter().enumerate() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
if !indexed {
let files = self.commit_files(&id.to_string())?;
let paths =
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let c = index.get(&repo, *id)?;
let message = index.message(&repo, *id)?;
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
if i % 100 == 0 {
index.flush();
}
}
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
}
Ok(())
})();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
result?;
Ok(self.log_index_progress())
}
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
let _scope = crate::ExecutionScope::enter(allowed);
let repo = self.gix()?;
let object =
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
let commit_id = object.id;
let id = commit_id.to_string();
let contains = format!("--contains={id}");
let branches =
cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?;
let cache_key = format!("{allowed}:{id}");
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
let mut details = if let Some(details) = cached {
details
} else {
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
let headers = &object.data[..end];
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
let output = if allowed && signed {
Some(cli::run_cancellable(
self.root(),
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
cancel,
)?)
} else {
None
};
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
let fields: Vec<_> = text.trim_end().split('\0').collect();
let message = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let message = index.message(&repo, commit_id)?;
index.flush();
message
};
let details = LogDetails {
message,
containing_branches: Vec::new(),
signature: if !signed {
"N".into()
} else {
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
},
signer: fields.get(1).unwrap_or(&"").to_string(),
key: fields.get(2).unwrap_or(&"").to_string(),
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
};
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
details
};
details.containing_branches =
branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect();
Ok(details)
}
/// Lists one immutable revision directory. Directory names end in `/`.
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let directory = self.rel_path(std::path::Path::new(directory))?;
let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") };
let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter_map(|line| {
let split = line.iter().position(|b| *b == b'\t')?;
let name = String::from_utf8_lossy(&line[split + 1..]);
let dir = line.starts_with(b"040000 ");
Some(format!(
"{}{name}{}",
if directory.is_empty() { String::new() } else { format!("{directory}/") },
if dir { "/" } else { "" }
))
})
.collect())
}
pub fn revision_files(&self, revision: &str) -> IdeResult<Vec<String>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let repo = self.gix()?;
let id = repo::resolve(&repo, revision)?.to_string();
let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?;
Ok(out
.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.map(|p| String::from_utf8_lossy(p).into_owned())
.collect())
}
/// Files changed by `commit` relative to its first parent (renames detected).
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
+385
View File
@@ -0,0 +1,385 @@
//! Immutable commit metadata, persisted by object id. Ref decorations and graph
//! projections are deliberately never cached as commit metadata.
use crate::Commit;
use crate::repo::{gx, to_string};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::IdeResult;
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::SystemTime;
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2");
const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1");
const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1");
pub(crate) struct IndexedView {
pub commits: Vec<Commit>,
pub positions: HashMap<String, usize>,
}
impl IndexedView {
pub fn new(commits: Vec<Commit>) -> Self {
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
Self { commits, positions }
}
}
#[derive(Default)]
pub(crate) struct LogIndex {
opened: bool,
db: Option<Arc<Database>>,
memory: HashMap<ObjectId, Arc<Commit>>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
dirty_search: Vec<(String, Vec<String>)>,
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
dirty_paths: Vec<(String, Option<(String, u16)>)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
pub shallow: HashSet<ObjectId>,
}
impl LogIndex {
/// A permanent DAG: refresh discovers only new objects, then joins arbitrary
/// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs.
pub fn graph(
&mut self,
repo: &gix::Repository,
tips: &[ObjectId],
shallow: &HashSet<ObjectId>,
first_parent: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<ObjectId>> {
if &self.shallow != shallow {
self.nodes.clear();
self.shallow = shallow.clone();
}
let mut pending = tips.to_vec();
while let Some(id) = pending.pop() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if self.nodes.contains_key(&id) {
continue;
}
let record = self.get(repo, id)?;
let parents: Vec<_> = if shallow.contains(&id) {
Vec::new()
} else {
record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::<IdeResult<_>>()?
};
pending.extend(parents.iter().copied());
self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64));
}
let mut seen = HashSet::new();
let mut ids = Vec::new();
let mut pending = tips.to_vec();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
ids.push(id);
if let Some((parents, _)) = self.nodes.get(&id) {
pending.extend(parents.iter().take(if first_parent { 1 } else { usize::MAX }).rev().copied());
}
}
Ok(ids)
}
pub fn indexed(&self, id: &str) -> bool {
self.db
.as_ref()
.and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(INDEXED).ok()?;
Some(table.get(id).ok()??.value())
})
.unwrap_or(false)
}
pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) {
let mut keys: HashSet<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
for user in [author, email, email.split('@').next().unwrap_or(email)] {
keys.insert(format!("u:{}", normalize_user(user)));
}
for path in paths {
keys.insert(format!("p:{path}"));
}
self.dirty_search.push((id.into(), keys.into_iter().collect()));
}
fn grams(text: &str) -> Vec<String> {
let chars: Vec<_> = text.to_lowercase().chars().collect();
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
}
/// Missing/unindexed commits are always checked directly by the caller.
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
let grams = Self::grams(text);
if grams.is_empty() {
return None;
}
let db = self.db.as_ref()?;
let txn = db.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut candidates: Option<HashSet<String>> = None;
for gram in grams {
let values = table.get(format!("m:{gram}").as_str()).ok()?;
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
candidates = Some(match candidates {
Some(old) => old.intersection(&ids).cloned().collect(),
None => ids,
});
if candidates.as_ref().is_some_and(HashSet::is_empty) {
break;
}
}
Some(candidates.unwrap_or_default())
}
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for author in authors {
let name = author.split('<').next().unwrap_or(author).trim();
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for path in paths {
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
let prefix = format!("p:{path}/");
let end = format!("{prefix}{}", char::MAX);
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
for value in row.1.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn open(&mut self, dir: &Path) {
if self.opened {
return;
}
self.opened = true;
// Several windows can read the same worktree; redb permits one owning
// handle per file, shared here rather than silently disabling its index.
static DATABASES: OnceLock<Mutex<HashMap<std::path::PathBuf, Weak<Database>>>> = OnceLock::new();
let path = dir.join("commits.redb");
let mut databases = DATABASES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner());
self.db = databases.get(&path).and_then(Weak::upgrade).or_else(|| {
let db = Arc::new(std::fs::create_dir_all(dir).ok().and_then(|_| Database::create(&path).ok())?);
databases.insert(path, Arc::downgrade(&db));
Some(db)
});
}
pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<Arc<Commit>> {
if let Some(commit) = self.memory.get(&id) {
return Ok(commit.clone());
}
let key = id.to_string();
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(COMMITS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
});
let commit = if let Some(commit) = cached {
Arc::new(commit)
} else {
let commit = repo.find_commit(id).map_err(gx)?;
let author = commit.author().map_err(gx)?;
let committer = commit.committer().map_err(gx)?;
let raw = commit.message_raw_sloppy();
let summary = raw.split(|b| *b == b'\n').find(|l| !l.trim().is_empty()).unwrap_or_default().trim();
let summary_text = to_string(summary.as_bstr());
let record = Arc::new(Commit {
id: key.clone(),
short_id: key[..key.len().min(8)].to_string(),
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
graph_parents: None,
graph_dashed: Vec::new(),
dashed: Vec::new(),
on_current_branch: None,
author: to_string(author.name.trim().as_bstr()),
email: to_string(author.email.trim().as_bstr()),
committer: to_string(committer.name.trim().as_bstr()),
committer_email: to_string(committer.email.trim().as_bstr()),
time_ms: author.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
commit_time_ms: committer.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
summary: summary_text.clone(),
message: summary_text,
message_truncated: raw.trim() != summary,
history_path: None,
log_position: None,
log_total: None,
graph_boundary_parents: Vec::new(),
refs: Vec::new(),
});
if self.db.is_some() {
self.dirty.push(record.clone());
}
record
};
// Keep hot pages in memory; older metadata stays in the persistent index.
if self.memory.len() >= 4096 {
self.flush();
self.memory.clear();
}
self.memory.insert(id, commit.clone());
Ok(commit)
}
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
return Ok(message.clone());
}
let key = id.to_string();
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(MESSAGES).ok()?;
Some(table.get(key.as_str()).ok()??.value().to_string())
});
if let Some(message) = cached {
return Ok(message);
}
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
if self.db.is_some() {
self.dirty_messages.push((key, message.clone()));
}
Ok(message)
}
pub fn flush(&mut self) {
if self.dirty.is_empty()
&& self.dirty_paths.is_empty()
&& self.dirty_messages.is_empty()
&& self.dirty_search.is_empty()
{
return;
}
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
let Some(db) = &self.db else {
return Ok(());
};
let txn = db.begin_write()?;
{
let mut table = txn.open_table(COMMITS)?;
for commit in &self.dirty {
let bytes = serde_json::to_vec(commit.as_ref())?;
table.insert(commit.id.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(PATHS)?;
for (key, value) in &self.dirty_paths {
let bytes = serde_json::to_vec(value)?;
table.insert(key.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(MESSAGES)?;
for (id, message) in &self.dirty_messages {
table.insert(id.as_str(), message.as_str())?;
}
}
{
let mut postings = txn.open_multimap_table(POSTINGS)?;
let mut indexed = txn.open_table(INDEXED)?;
for (id, keys) in &self.dirty_search {
for key in keys {
postings.insert(key.as_str(), id.as_str())?;
}
indexed.insert(id.as_str(), true)?;
}
}
txn.commit()?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!("Git log cache write failed: {error}");
}
self.dirty_messages.clear();
self.dirty_search.clear();
self.dirty.clear();
self.dirty_paths.clear();
}
pub fn path_entry(
&mut self,
repo: &gix::Repository,
id: ObjectId,
path: &str,
) -> IdeResult<Option<(ObjectId, u16)>> {
let memory_key = (id, path.to_string());
if let Some(value) = self.path_memory.get(&memory_key) {
return Ok(*value);
}
let key = format!("{id}\0{path}");
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(PATHS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
});
let cached = match cached {
Some(None) => Some(None),
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
None => None,
};
let value = if let Some(value) = cached {
value
} else {
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
let value = tree
.lookup_entry_by_path(Path::new(path))
.map_err(gx)?
.map(|entry| (entry.object_id(), entry.mode().value()));
if self.db.is_some() {
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
}
value
};
if self.path_memory.len() >= 4096 {
self.flush();
self.path_memory.clear();
}
self.path_memory.insert(memory_key, value);
Ok(value)
}
}
pub(crate) fn normalize_user(user: &str) -> String {
user.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool {
let query_name = query.split('<').next().unwrap_or(query).trim();
let query_email = query.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(query);
let target =
[normalize_user(name), normalize_user(email), normalize_user(email.split('@').next().unwrap_or(email))];
[query_name, query_email, query_email.split('@').next().unwrap_or(query_email)]
.iter()
.any(|alias| target.contains(&normalize_user(alias)))
}
+3
View File
@@ -64,6 +64,9 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
git_dir,
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
+438
View File
@@ -0,0 +1,438 @@
use ide_vcs::{LogOrder, LogQuery, Repo};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args([
"-c",
"init.defaultBranch=main",
"-c",
"user.name=Test",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(dir: &Path, file: &str, message: &str) -> String {
std::fs::write(dir.join(file), message).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-qm", message]);
git(dir, &["rev-parse", "HEAD"])
}
#[test]
fn branch_and_independent_ranges_form_a_union() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
let first = commit(dir, "a", "first");
let second = commit(dir, "a", "second");
let third = commit(dir, "a", "third");
git(dir, &["checkout", "-qb", "side", &root]);
let side = commit(dir, "b", "side");
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
let log = repo
.log(&LogQuery {
branches: vec!["side".into()],
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
order,
..Default::default()
})
.unwrap();
let got: std::collections::HashSet<_> = log.iter().map(|c| c.id.clone()).collect();
assert_eq!(got, [root.clone(), first.clone(), third.clone(), side.clone()].into());
let ranges = repo
.log(&LogQuery {
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
order,
..Default::default()
})
.unwrap();
assert_eq!(
ranges.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>(),
[first.clone(), third.clone()].into()
);
let positions: std::collections::HashMap<_, _> =
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
for (i, c) in log.iter().enumerate() {
for p in c.graph_parents.as_ref().unwrap() {
assert!(positions.get(p.as_str()).is_none_or(|p| *p > i));
}
}
}
}
#[test]
fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let mut ids = Vec::new();
for i in 0..70 {
ids.push(commit(dir, "a", &format!("summary {i}\n\nneedle body {i}")));
}
let cache = tempfile::tempdir().unwrap();
let repo = Repo::discover(dir).unwrap().unwrap();
repo.enable_log_index(cache.path());
let query = LogQuery { summary_only: true, limit: 5, ..Default::default() };
let page = repo.log(&query).unwrap();
assert_eq!(page[0].message, "summary 69");
assert!(page[0].message_truncated);
assert_eq!(page[0].log_position, Some(0));
assert_eq!(page[0].log_total, Some(70));
let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap();
assert!(window.iter().any(|c| c.id == ids[5]));
assert!(window[0].log_position.unwrap() > 0);
assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok());
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert!(repo.log_cancellable(&query, &cancel).is_err());
let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap();
assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false));
assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5);
let new = commit(dir, "b", "new head");
let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap();
assert_eq!(refreshed[0].id, new);
assert_eq!(refreshed.len(), 71);
drop(repo);
let repo = Repo::discover(dir).unwrap().unwrap();
repo.enable_log_index(cache.path());
let hit = repo.log(&LogQuery { text: Some("needle body 69".into()), ..query }).unwrap();
assert_eq!(hit[0].id, ids[69]);
assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69");
assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N");
}
#[test]
fn followed_history_carries_the_filename_at_each_revision() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let first = commit(dir, "old.txt", "original");
git(dir, &["mv", "old.txt", "new.txt"]);
git(dir, &["commit", "-qm", "rename"]);
let renamed = git(dir, &["rev-parse", "HEAD"]);
let tip = commit(dir, "new.txt", "latest");
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
assert_eq!(
log.iter().map(|c| (c.id.as_str(), c.history_path.as_deref())).collect::<Vec<_>>(),
vec![(tip.as_str(), Some("new.txt")), (renamed.as_str(), Some("new.txt")), (first.as_str(), Some("old.txt"))]
);
assert_eq!(repo.revision_children(&first, "").unwrap(), vec!["old.txt"]);
}
#[test]
fn detail_filters_connect_visible_ancestors_without_changing_revision_parents() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let a = commit(dir, "a", "keep root");
commit(dir, "b", "hidden middle");
git(dir, &["commit", "--amend", "--no-edit", "--author=Other <[email protected]>"]);
let b = git(dir, &["rev-parse", "HEAD"]);
let c = commit(dir, "a", "keep tip");
let repo = Repo::discover(dir).unwrap().unwrap();
for q in [
LogQuery { text: Some("keep".into()), ..Default::default() },
LogQuery { path: Some("a".into()), ..Default::default() },
LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() },
] {
let page = repo.log(&LogQuery { limit: 1, ..q.clone() }).unwrap();
assert_eq!(page[0].id, c);
assert_eq!(page[0].parents, vec![b.clone()]);
assert_eq!(page[0].graph_parents, Some(vec![a.clone()]));
assert_eq!(page[0].graph_dashed, vec![true]);
assert_eq!(page[0].on_current_branch, Some(true));
let next = repo.log(&LogQuery { skip: 1, limit: 1, ..q }).unwrap();
assert_eq!(next[0].id, a);
assert_eq!(next[0].graph_parents, Some(vec![]));
}
}
#[test]
fn all_history_includes_commits_reachable_only_from_tags() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
let tip = commit(dir, "a", "tagged tip");
git(dir, &["tag", "v1"]);
git(dir, &["reset", "--hard", &root]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery::default()).unwrap();
let tagged = log.iter().find(|c| c.id == tip).unwrap();
assert!(tagged.refs.contains(&"tag: v1".into()));
assert_eq!(tagged.on_current_branch, Some(false));
}
#[test]
fn projection_does_not_truncate_long_hidden_chains() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let mut stream = String::new();
for i in 0..4100 {
let message = if i == 0 || i == 4099 { "keep" } else { "hide" };
stream.push_str(&format!(
"commit refs/heads/main\ncommitter Test <[email protected]> {} +0000\ndata {}\n{}\n\n",
1000 + i,
message.len(),
message
));
}
let mut child = Command::new("git")
.current_dir(dir)
.args(["fast-import", "--quiet"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap();
assert!(child.wait().unwrap().success());
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { text: Some("keep".into()), ..Default::default() }).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].graph_parents, Some(vec![log[1].id.clone()]));
}
#[test]
fn path_and_shallow_history_remain_topological_with_clock_skew() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
std::fs::create_dir(&source).unwrap();
git(&source, &["init", "-q"]);
let make = |message: &str, parent: Option<&str>, time: &str| {
std::fs::write(source.join("a.txt"), message).unwrap();
git(&source, &["add", "a.txt"]);
let tree = git(&source, &["write-tree"]);
let mut cmd = Command::new("git");
cmd.current_dir(&source).args([
"-c",
"user.name=Test",
"-c",
"[email protected]",
"commit-tree",
&tree,
"-m",
message,
]);
if let Some(p) = parent {
cmd.args(["-p", p]);
}
let out = cmd.env("GIT_AUTHOR_DATE", time).env("GIT_COMMITTER_DATE", time).output().unwrap();
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap().trim().to_string()
};
let root = make("root", None, "@5000 +0000");
let left = make("left", Some(&root), "@1000 +0000");
let right = make("right", Some(&root), "@2000 +0000");
git(&source, &["update-ref", "refs/heads/main", &left]);
git(&source, &["update-ref", "refs/heads/side", &right]);
let shallow = tmp.path().join("shallow");
git(
tmp.path(),
&[
"clone",
"-q",
"--depth=2",
"--no-single-branch",
&format!("file://{}", source.display()),
shallow.to_str().unwrap(),
],
);
for dir in [&source, &shallow] {
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
let log = repo.log(&LogQuery { order, path: Some("a.txt".into()), ..Default::default() }).unwrap();
assert_eq!(log.len(), 3);
let positions: std::collections::HashMap<_, _> =
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
for (i, c) in log.iter().enumerate() {
for parent in c.graph_parents.as_ref().unwrap() {
assert!(positions.get(parent.as_str()).is_none_or(|p| *p > i));
}
}
}
}
}
#[test]
fn multiple_paths_exact_authors_patterns_and_rename_history() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "old.txt", "root");
git(dir, &["mv", "old.txt", "new.txt"]);
git(dir, &["commit", "-qm", "rename"]);
let rename = git(dir, &["rev-parse", "HEAD"]);
let tip = commit(dir, "other.txt", "another file");
git(dir, &["branch", "feature/log"]);
git(dir, &["branch", "feature/skip", &root]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { paths: vec!["new.txt".into(), "other.txt".into()], ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![tip.clone(), rename.clone()]);
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![rename, root.clone()]);
let log = repo
.log(&LogQuery { branches: vec!["feature/.*".into(), "-feature/skip".into()], ..Default::default() })
.unwrap();
assert_eq!(log[0].id, tip);
std::fs::write(dir.join("other.txt"), "different author").unwrap();
git(dir, &["add", "-A"]);
git(dir, &["-c", "user.name=Test User", "-c", "[email protected]", "commit", "-qm", "different author"]);
let log = repo.log(&LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }).unwrap();
assert_eq!(log.len(), 3);
assert!(log.iter().all(|c| c.author == "Test"));
assert!(repo.log_users().unwrap().contains(&"Test User <[email protected]>".to_string()));
}
#[test]
fn metadata_and_path_cache_survive_restart_without_caching_refs_or_filters() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("repo");
std::fs::create_dir(&dir).unwrap();
git(&dir, &["init", "-q"]);
let first = commit(&dir, "a", "first\n\nComplete commit body");
git(&dir, &["tag", "v1"]);
git(&dir, &["tag", "-a", "annotated", "-m", "release"]);
let cache = tmp.path().join("cache");
{
let repo = Repo::discover(&dir).unwrap().unwrap();
repo.enable_log_index(&cache);
let log = repo.log(&LogQuery { path: Some("a".into()), summary_only: true, ..Default::default() }).unwrap();
assert!(log[0].message_truncated);
assert_eq!(log[0].message, "first");
assert_eq!(repo.log_details(&first).unwrap().message, "first\n\nComplete commit body");
let details = repo.log_details("annotated").unwrap();
assert_eq!(details.message, "first\n\nComplete commit body");
assert_eq!(details.signature, "N");
assert!(details.containing_branches.contains(&"main".to_string()));
assert_eq!(repo.revision_files(&first).unwrap(), vec!["a"]);
}
let second = commit(&dir, "a", "second");
git(&dir, &["tag", "-f", "v1"]);
let repo = Repo::discover(&dir).unwrap().unwrap();
repo.enable_log_index(&cache);
let log = repo.log(&LogQuery { path: Some("a".into()), ..Default::default() }).unwrap();
assert_eq!(log[0].id, second);
assert!(log[0].refs.contains(&"tag: v1".into()));
assert!(!log[1].refs.contains(&"tag: v1".into()));
assert!(log[1].message.contains("Complete commit body"));
assert!(!log[1].message_truncated);
}
#[test]
fn cancelled_query_does_not_publish_a_partial_page() {
let tmp = tempfile::tempdir().unwrap();
git(tmp.path(), &["init", "-q"]);
commit(tmp.path(), "a", "root");
let repo = Repo::discover(tmp.path()).unwrap().unwrap();
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert_eq!(
repo.log_cancellable(&LogQuery::default(), &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn shallow_boundaries_do_not_reconnect_to_locally_present_ancestors() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "keep root");
let boundary = commit(dir, "a", "hidden boundary");
let tip = commit(dir, "a", "keep tip");
git(dir, &["tag", "root", &root]);
std::fs::write(dir.join(".git/shallow"), format!("{boundary}\n")).unwrap();
let repo = Repo::discover(dir).unwrap().unwrap();
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
for text in [None, Some("keep".to_string())] {
let log = repo.log(&LogQuery { order, text, path: Some("a".into()), ..Default::default() }).unwrap();
let boundary_row = log.iter().find(|c| c.id == boundary);
if let Some(row) = boundary_row {
assert_eq!(row.parents, vec![root.clone()]);
assert_eq!(row.graph_parents, Some(vec![]));
} else {
assert_eq!(log.iter().find(|c| c.id == tip).unwrap().graph_parents, Some(vec![]));
}
}
}
}
#[test]
fn exact_branch_exclusions_treat_punctuation_literally() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "a", "root");
git(dir, &["branch", "release/1.0"]);
let tip = commit(dir, "a", "tip");
git(dir, &["branch", "release/1x0"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo
.log(&LogQuery { branches: vec!["release/.*".into(), "-release/1.0".into()], ..Default::default() })
.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
}
#[test]
fn mode_only_changes_appear_in_path_history() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let root = commit(dir, "script", "root");
git(dir, &["update-index", "--chmod=+x", "script"]);
git(dir, &["commit", "-qm", "make executable"]);
let tip = git(dir, &["rev-parse", "HEAD"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { path: Some("script".into()), ..Default::default() }).unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
}
#[test]
fn multiple_paths_are_simplified_together_at_merges() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
commit(dir, "a", "a0");
commit(dir, "b", "b0");
git(dir, &["branch", "side"]);
commit(dir, "a", "left");
git(dir, &["checkout", "side"]);
commit(dir, "b", "right");
git(dir, &["checkout", "main"]);
git(dir, &["merge", "--no-ff", "side", "-m", "merge both paths"]);
let repo = Repo::discover(dir).unwrap().unwrap();
let expected = git(dir, &["log", "--topo-order", "--format=%H", "--", "a", "b"]);
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
let log = repo
.log(&LogQuery {
branch: Some("HEAD".into()),
paths: vec!["a".into(), "b".into()],
order,
..Default::default()
})
.unwrap();
assert_eq!(log[0].summary, "merge both paths");
assert_eq!(
log.iter().map(|c| c.id.as_str()).collect::<std::collections::HashSet<_>>(),
expected.lines().collect()
);
assert_eq!(log[0].parents.len(), 2);
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
}
}
+20 -4
View File
@@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec<String> {
}
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
commits.iter().find(|c| c.id == id).unwrap().parents.clone()
commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap()
}
#[test]
@@ -75,6 +75,14 @@ fn date_order_follows_commit_time_and_reports_it() {
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
}
#[test]
fn incoming_order_groups_the_merged_branch_before_the_main_parent() {
let h = history();
let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
fn no_merges_links_children_to_the_merge_parents() {
let h = history();
@@ -90,6 +98,7 @@ fn first_parent_skips_merged_branches() {
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]);
}
#[test]
@@ -201,19 +210,26 @@ fn a_user_filter_links_shown_commits_past_hidden_ones() {
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
.iter()
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
.map(|c| {
(
c.summary.as_str(),
c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(),
if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() },
)
})
.collect();
assert_eq!(
links,
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
"m2's parent o1 is hidden: m2 links to m1, dashed"
);
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1)));
assert_eq!(log[1].parents, vec![o1]);
// A page ends at m2: walking on still links it to m1.
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1));
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
let _ = m3;
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
specta.workspace = true
ignore.workspace = true
globset.workspace = true
notify-debouncer-full.workspace = true
notify.workspace = true
encoding_rs.workspace = true
chardetng.workspace = true
trash.workspace = true
+5
View File
@@ -256,6 +256,11 @@ pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::rename(from, to)
}
/// Checks source existence and destination conflicts without mutation, using
/// the same filesystem identity rules as [`rename`].
pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::validate_rename(from, to)
}
/// Moves to the OS trash (recoverable), like IDEA's safe delete for files.
/// Deletes permanently when the trash is unavailable.
pub fn delete(path: &Path) -> IdeResult<()> {
+9 -1
View File
@@ -88,7 +88,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
if !exists(from) {
return Err(not_found(from));
}
@@ -99,6 +99,14 @@ pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
if exists(to) && !same_file(from, to) {
return Err(already_exists(to));
}
Ok(())
}
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
validate_rename(from, to)?;
if from == to {
return Ok(());
}
ensure_parent(to)?;
match fs::rename(from, to) {
Ok(()) => Ok(()),
+163 -92
View File
@@ -12,9 +12,8 @@
use crate::FsBatch;
use crate::rules::{ExcludeMatcher, GitignoreStack};
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use parking_lot::{Condvar, Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Component, Path, PathBuf};
@@ -62,10 +61,9 @@ pub struct Watcher {
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
enum Inner {
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
struct Inner {
state: Arc<DirState>,
thread: Option<JoinHandle<()>>,
}
impl Watcher {
@@ -131,80 +129,32 @@ impl Watcher {
/// Number of OS-level watches (1 in recursive and flat mode).
pub fn watch_count(&self) -> usize {
match &self.inner {
Inner::Recursive(_) | Inner::Polling(_) => 1,
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
}
self.inner.state.watched.lock().len()
}
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default();
match mode {
WatchMode::Recursive => {
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
}
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
}
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
}
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
}
/// Polls per directory unless flat: a recursive poll would rescan
/// `node_modules` and `target` every round.
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
match mode {
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
}
WatchMode::Recursive | WatchMode::PerDirectory => {
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
}
}
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
}
fn start_debounced<W: notify::Watcher>(
root: &Path,
filter: EventFilter,
emit: Emit,
recursive: RecursiveMode,
config: notify::Config,
) -> IdeResult<Debouncer<W, NoCache>> {
let polled = W::kind() == WatcherKind::PollWatcher;
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
// which made opening large projects block for seconds. Renames arrive as remove + create.
let handler = move |result: DebounceEventResult| match result {
Ok(mut events) => {
if polled {
events.retain(|e| !is_dir_touch(&e.event));
}
let (batch, _) = filter.batch_detailed(&events);
if !batch.is_empty() {
emit(&batch);
}
}
Err(errors) => {
for e in errors {
tracing::warn!("file watcher: {e}");
}
}
};
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
Ok(debouncer)
}
fn start_per_directory<W: notify::Watcher + Send + 'static>(
fn start_queued<W: notify::Watcher + Send + 'static>(
root: &Path,
filter: EventFilter,
emit: Emit,
mode: WatchMode,
config: notify::Config,
) -> IdeResult<Inner> {
let polled = W::kind() == WatcherKind::PollWatcher;
let state = Arc::new(DirState {
filter,
mode,
watcher: Mutex::new(None),
watched: Mutex::default(),
queue: Mutex::default(),
@@ -228,8 +178,11 @@ impl Watcher {
)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
*state.watcher.lock() = Some(Box::new(watcher));
let mut created = Vec::new();
state.watch_tree(root, false, &mut created);
if mode == WatchMode::PerDirectory {
state.watch_tree(root, false, &mut Vec::new());
} else {
state.add_watch(root);
}
if state.watched.lock().is_empty() {
return Err(io_error(format!("cannot watch {}", root.display())));
}
@@ -240,20 +193,22 @@ impl Watcher {
.spawn(move || state.run(&*emit))
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
};
Ok(Inner::PerDirectory { state, thread: Some(thread) })
Ok(Inner { state, thread: Some(thread) })
}
}
impl Drop for Watcher {
impl Drop for Inner {
fn drop(&mut self) {
if let Inner::PerDirectory { state, thread } = &mut self.inner {
let state = &self.state;
{
let _queue = state.queue.lock();
state.stop.store(true, Ordering::SeqCst);
state.wake.notify_all();
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = thread.take() {
let _ = t.join();
}
}
let watcher = state.watcher.lock().take();
drop(watcher);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
@@ -268,10 +223,10 @@ fn is_dir_touch(event: &notify::Event) -> bool {
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
}
/// State of the per-directory strategy, shared with the notify callback
/// (weakly) and the debounce thread.
/// Shared with the notify callback (weakly) and the debounce thread.
struct DirState {
filter: EventFilter,
mode: WatchMode,
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
watched: Mutex<HashSet<PathBuf>>,
queue: Mutex<Vec<(notify::Event, Instant)>>,
@@ -310,9 +265,13 @@ impl DirState {
}
std::mem::take(&mut *q)
};
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
// A scan may publish a path before its create event is delivered:
// keep create + remove pairs so the consumer still sees the removal.
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
self.update_watches(&mut batch, &rule_dirs);
if self.mode == WatchMode::PerDirectory {
self.update_watches(&mut batch, &rule_dirs);
}
if !batch.is_empty() && !self.stopped() {
emit(&batch);
}
@@ -363,7 +322,9 @@ impl DirState {
}
let mut guard = self.watcher.lock();
let Some(w) = guard.as_mut() else { return false };
match w.watch(dir, RecursiveMode::NonRecursive) {
let recursive =
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
match w.watch(dir, recursive) {
Ok(()) => {
self.watched.lock().insert(dir.to_path_buf());
true
@@ -555,17 +516,17 @@ impl EventFilter {
}
#[cfg(test)]
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
pub fn batch(&self, events: &[Event]) -> FsBatch {
self.batch_detailed(events).0
}
/// The coalesced, filtered batch plus the directories whose ignore rules
/// changed (their cached matchers have been rebuilt already).
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
for ev in events {
let paths = &ev.event.paths;
match ev.event.kind {
let paths = &ev.paths;
match ev.kind {
EventKind::Access(_) => {}
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
@@ -601,7 +562,7 @@ impl EventFilter {
rule_dirs.insert(dir.to_path_buf());
}
}
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
self.gitignore.reload_repo_level();
rule_dirs.insert(self.root.clone());
}
@@ -669,11 +630,125 @@ impl EventFilter {
#[cfg(test)]
mod tests {
use super::*;
use notify_debouncer_full::notify::Event;
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
use notify::event::{CreateKind, DataChange, RemoveKind};
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event { kind, paths, attrs: Default::default() }
}
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
/// Delivers a whole debounce window without depending on OS scheduling.
struct TransientWatcher(Box<dyn notify::EventHandler>);
impl notify::Watcher for TransientWatcher {
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
Ok(Self(Box::new(handler)))
}
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
for rel in TRANSIENT_PATHS {
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
EventKind::Remove(RemoveKind::File),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
}
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
}
for (rel, kind) in [
("from.py", EventKind::Create(CreateKind::File)),
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
("to.py", EventKind::Remove(RemoveKind::File)),
] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
}
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
}
Ok(())
}
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
Ok(())
}
fn kind() -> WatcherKind {
WatcherKind::NullWatcher
}
}
#[test]
fn debounce_preserves_transient_removals_and_atomic_replacements() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
let _watcher = Watcher::start_queued::<TransientWatcher>(
&root,
EventFilter::new(root.clone(), &[]),
emit,
mode,
notify::Config::default(),
)
.unwrap();
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
let expected =
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
assert_eq!(batch.modified, vec![root.join("kept.py")]);
assert!(batch.created.is_empty());
}
}
#[cfg(windows)]
#[test]
fn native_watch_reports_quickly_removed_files() {
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
let watcher = {
let batches = batches.clone();
Watcher::start_with(
&root,
Vec::new(),
Arc::new(ide_core::NullSink),
Box::new(move |b| batches.lock().push(b.clone())),
mode,
)
.unwrap()
};
assert!(watcher.polling().is_none());
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
let paths: Vec<_> = TRANSIENT_PATHS
.iter()
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
.map(|rel| root.join(rel))
.collect();
for path in &paths {
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
std::fs::remove_file(path).unwrap();
}
assert!(
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
"{mode:?}: {:?}",
batches.lock()
);
drop(watcher);
let count = batches.lock().len();
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
std::thread::sleep(DEBOUNCE * 2);
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
}
}
#[test]
@@ -822,8 +897,7 @@ mod tests {
}
let filter = EventFilter::new(root.clone(), &[]);
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
let touch =
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
@@ -873,10 +947,7 @@ mod tests {
}
fn watcher_state(w: &Watcher) -> &DirState {
match &w.inner {
Inner::PerDirectory { state, .. } => state,
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
}
&w.inner.state
}
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
+1
View File
@@ -21,6 +21,7 @@ blake3.workspace = true
tracing.workspace = true
tempfile.workspace = true
globset.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -401,8 +401,11 @@ impl Documents {
if edit.files.is_empty() {
return Ok(());
}
// Validate project rules before path resolution can erase linked components.
crate::file_ops::validate_workspace_edit_paths(edit)?;
let mut edit = normalize_edit(edit)?;
let captured = !edit.preconditions.is_empty();
crate::file_ops::expand_workspace_edit(&mut edit)?;
self.prepare_workspace_edit(&mut edit)?;
self.validate_preconditions(&edit.preconditions)?;
for file in &edit.files {
+16 -27
View File
@@ -120,6 +120,8 @@ impl FileMoves {
if moves.is_empty() {
return Err(IdeError::invalid("nothing to move"));
}
let expanded = crate::file_ops::expand_moves(moves)?;
let moves = expanded.as_slice();
for m in moves {
if !m.from.exists() {
return Err(IdeError::not_found(format!("{} does not exist", m.from.display())));
@@ -145,16 +147,9 @@ impl FileMoves {
restore(documents, &paths, &before);
return Err(e);
}
let mut done: Vec<&FileMove> = Vec::new();
for m in moves {
if let Err(e) = ide_vfs::rename(&m.from, &m.to) {
for d in done.iter().rev() {
let _ = ide_vfs::rename(&d.to, &d.from);
}
restore(documents, &paths, &before);
return Err(e);
}
done.push(m);
if let Err(e) = crate::file_ops::move_files(moves) {
restore(documents, &paths, &before);
return Err(e);
}
let mut edited = Vec::with_capacity(paths.len());
for (p, before) in paths.iter().zip(before) {
@@ -196,27 +191,21 @@ impl FileMoves {
.ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?;
records.remove(i)
};
for m in &record.moves {
if !m.to.exists() {
return Err(IdeError::not_found(format!("{} no longer exists", m.to.display())));
}
if m.from.exists() {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("{} exists again", m.from.display()),
));
}
let back: Vec<_> =
record.moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
if let Err(e) = crate::file_ops::move_files(&back) {
self.records.lock().push(record);
return Err(e);
}
let mut outcome = UndoOutcome::default();
for m in record.moves.iter().rev() {
ide_vfs::rename(&m.to, &m.from)?;
outcome.moves.push(FileMove { from: m.to.clone(), to: m.from.clone() });
}
let back: Vec<FileMove> = outcome.moves.clone();
let mut outcome = UndoOutcome { moves: back.clone(), conflicts: Vec::new() };
for f in &record.edited {
let path = moved_path(&f.path, &back);
match read_text(documents, &path) {
Ok(now) if now == f.after => write_text(documents, &path, &f.before)?,
Ok(now) if now == f.after => {
if write_text(documents, &path, &f.before).is_err() {
outcome.conflicts.push(path.into());
}
}
Ok(now) if now == f.before => {}
_ => outcome.conflicts.push(path.into()),
}
+94
View File
@@ -0,0 +1,94 @@
//! Project-aware file operations. Plain files retain the VFS behavior; project
//! rules may expand a resource operation before the common transaction runs.
use crate::FileMove;
use ide_core::{IdeError, IdeResult, WorkspaceEdit};
use std::path::{Path, PathBuf};
mod project;
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
project::copy(from, to).unwrap_or_else(|| ide_vfs::copy(from, to))
}
pub fn delete(path: &Path) -> IdeResult<()> {
project::delete(path).unwrap_or_else(|| ide_vfs::delete(path))
}
/// Files Local History must capture before deletion.
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
project::deletion_paths(path).unwrap_or_else(|| Ok(vec![path.to_path_buf()]))
}
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
if !project::has_move_rules(from, to) {
return ide_vfs::rename(from, to);
}
let moves = expand_moves(&[FileMove { from: from.into(), to: to.into() }])?;
move_files(&moves)
}
pub(crate) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
let expanded = project::expand_moves(moves)?;
validate_moves(&expanded)?;
Ok(expanded)
}
fn path_key(path: &Path) -> PathBuf {
#[cfg(windows)]
return path.to_string_lossy().replace('\\', "/").to_lowercase().into();
#[cfg(not(windows))]
path.to_path_buf()
}
fn validate_moves(moves: &[FileMove]) -> IdeResult<()> {
let mut sources = std::collections::HashSet::new();
let mut targets = std::collections::HashSet::new();
for m in moves {
ide_vfs::validate_rename(&m.from, &m.to)?;
let from = path_key(&m.from);
let to = path_key(&m.to);
if to != from && to.starts_with(&from) {
return Err(IdeError::invalid("Cannot move a directory into itself"));
}
if !sources.insert(from) || !targets.insert(to) {
return Err(IdeError::invalid("Duplicate source or destination in file operation"));
}
}
Ok(())
}
pub(crate) fn move_files(moves: &[FileMove]) -> IdeResult<()> {
execute_moves(moves, &|from, to| project::rename_entry(from, to).unwrap_or_else(|| ide_vfs::rename(from, to)))
}
/// Executes an expanded batch. Rollback never overwrites a newly occupied path;
/// failures report where the caller can recover the remaining files.
pub(crate) fn execute_moves(moves: &[FileMove], rename: &dyn Fn(&Path, &Path) -> IdeResult<()>) -> IdeResult<()> {
validate_moves(moves)?;
for (i, m) in moves.iter().enumerate() {
if let Err(mut error) = rename(&m.from, &m.to) {
for done in moves[..i].iter().rev() {
if let Err(rollback) =
ide_vfs::validate_rename(&done.to, &done.from).and_then(|()| rename(&done.to, &done.from))
{
error.message.push_str(&format!(
"; rollback {} -> {} failed: {rollback}",
done.to.display(),
done.from.display()
));
}
}
return Err(error);
}
}
Ok(())
}
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
project::validate_workspace_edit_paths(edit)
}
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
project::expand_workspace_edit(edit)
}
@@ -0,0 +1,52 @@
//! The integration boundary for project-specific resource rules. These rules
//! never replace the plain VFS path unless an operation touches project assets.
use crate::{FileMove, unity::assets};
use ide_core::{FileEdit, IdeResult, WorkspaceEdit};
use std::path::{Path, PathBuf};
pub(super) fn has_move_rules(from: &Path, to: &Path) -> bool {
assets::project_root(from).is_some() || assets::project_root(to).is_some()
}
pub(super) fn copy(from: &Path, to: &Path) -> Option<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::copy(from, to))
}
pub(super) fn delete(path: &Path) -> Option<IdeResult<()>> {
assets::project_root(path).map(|_| assets::delete(path))
}
pub(super) fn deletion_paths(path: &Path) -> Option<IdeResult<Vec<PathBuf>>> {
assets::project_root(path).map(|_| assets::deletion_paths(path))
}
pub(super) fn rename_entry(from: &Path, to: &Path) -> Option<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::move_one(from, to))
}
pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
if moves.iter().any(|m| has_move_rules(&m.from, &m.to)) { assets::expand_moves(moves) } else { Ok(moves.to_vec()) }
}
fn has_edit_rules(edit: &WorkspaceEdit) -> bool {
edit.files.iter().any(|file| match file {
FileEdit::Rename { from, to } => has_move_rules(from, to),
FileEdit::Delete { path, .. } => assets::project_root(path).is_some(),
_ => false,
})
}
pub(super) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
if has_edit_rules(edit) {
assets::validate_workspace_edit_paths(edit)?;
}
Ok(())
}
pub(super) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
if has_edit_rules(edit) {
assets::expand_workspace_edit(edit)?;
}
Ok(())
}
+2
View File
@@ -7,6 +7,7 @@ pub mod documents;
pub mod editorconfig;
pub mod file_defaults;
pub mod file_moves;
pub mod file_ops;
pub mod file_refs;
pub mod history;
pub mod memory;
@@ -15,6 +16,7 @@ pub mod recent;
pub mod roots;
pub mod settings;
pub mod text_refs;
pub mod unity;
pub mod window_groups;
pub mod workspace;
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more