Порівняти коміти

...
180 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai 60c6f49701 feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings
- Settings › Commit › "Use non-modal commit interface": when off, Local
  Changes, Shelf and Stash become tabs of the Git tool window and ⌘K opens
  a Commit dialog; switches live.
- Git tool window: Worktrees tab; Show History and Show Git Log for a
  branch open their own closable tabs instead of changing the Log filters.
- Log: toolbar and context menu for a commit's changed files, vertical
  toolbar, Tags node and hiding for the branches pane, Collapse / Expand
  Linear Branches as separate actions, and a Version Control › Log page.
- Branch popup: collapsible groups, branches grouped by prefix, an options
  menu behind the gear, incoming / outgoing icons and "Pull into … Using
  Rebase / Merge"; New Branch on ⌥⌘N outside the editor.
- Git main menu regrouped with Uncommitted Changes and Current File
  submenus; rebase / merge controls enabled only while one is in progress.
- Settings: Version Control lists its pages; new options for clearing the
  initial commit message, showing Update options, confirming dropped
  commits, revealing created patches, large-file and file-name warnings,
  the Push dialog after Commit and Push, combined stashes and shelves, and
  removing applied files from the shelf.
2026-10-28 21:36:32 +08:00
l0ng-ai 66c36a7361 fix(workbench): Open files dropped from the OS in the editor (#142)
Root cause: once a project was open, nothing outside the Project view handled files dropped from the OS, so drops onto the editor, tabs or other tool windows were ignored.

A window-wide handler now opens dropped files in the editor (one tab each, last one focused, files outside the project included) and opens a dropped folder as a project through the open-project policy. Regions that handle drops themselves (Project view, agent prompt box) are marked with OWNS_FILE_DROP and hit-tested, so each drop has a single handler; the Welcome screen and remote windows are left alone.
2026-10-28 20:58:42 +08:00
l0ng-ai 8daeb21005 fix(terminal): Line height, colour palette and tab names (#141)
* fix(terminal): Line height, colour palette and tab names

- Line height: with "Use editor font" the terminal took the editor's roomier
  line height (about 1.33 cells), stretching ASCII art and box drawing. It now
  always uses Settings › Tools › Terminal › Line height (default 1.0); that
  field moves out of the "Use editor font" group since it applies either way.
- Colours: the terminal gets its own --terminal-ansi-* palette (the light and
  Darcula terminal colours); the Run console keeps --ansi-*. High-contrast and
  palette themes keep their console colours. Colours below a 4.5:1 contrast
  with the background are adjusted (xterm's minimumContrastRatio), so yellow
  stays readable on white.
- Tab names: tabs keep their default name ("Local", "Local (2)") at the prompt
  and show the title a program sets only while a command runs. Shell
  integration now marks command start and end (OSC 133 C / D) for zsh,
  bash 4.4+ (PS0) and fish.

* fix(themes): Document the terminal ANSI palette for theme plugins

The --terminal-ansi-* tokens belong in a theme's editor colour scheme, like
--ansi-*. The theme plugin template sets them and the token reference lists them.
2026-10-28 20:56:38 +08:00
l0ng-ai 44b2d11b2d feat(agents): Select the active session in the Agents list (#140)
Switching between agent session tabs in the editor now moves the Agents
tool window's selection to that task, expanding its group (e.g. Archived)
when it is collapsed. The list also opens with the active session selected.
Tabs of other kinds leave the selection as it is.

The editor service gains activeInput() and onActiveInputChange() so
features can follow the active tab of any kind.
2026-10-28 20:36:55 +08:00
l0ng-ai f8b3ef6840 chore: Skip the repository-wide formatting commit in git blame (#139) 2026-10-28 19:35:52 +08:00
Jaw 55836e1b49 chore: Format the codebase with oxfmt and check formatting in CI (#135)
Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown.

Co-authored-by: jaw52 <[email protected]>
2026-10-28 19:35:16 +08:00
胡翀таzzxzz12345 a94b4bff42 feat(vcs): Search the Git log branch filter (#129)
The Branch filter in the Git log opens a popup with a search field instead of a plain menu. Branches are fuzzy-matched with highlights and grouped under HEAD, Local and Remote; arrow keys skip the group headers and Enter applies the filter. The list is virtualized so large repositories stay responsive, and Select… still opens the multi-branch / custom revision chooser.

Co-authored-by: zzxzz12345 <[email protected]>
2026-10-28 19:32:54 +08:00
l0ng-ai 3193fd583f chore: v0.1.22 2026-10-28 19:14:59 +08:00
l0ng-ai 995ea886ab fix(workbench): Keep tool window resizing smooth over a terminal or long conversation (#137)
Dragging the bottom tool window edge over a terminal refit xterm on every
pointer move and sent each intermediate size to the shell, which redrew its
prompt every time. Terminals now refit at most every 100 ms while their host
keeps resizing, plus once when it stops, and the process only learns the size
once it has held for 150 ms. This covers the Terminal tool window and terminals
embedded in Run consoles.

Resizing the width of an agent conversation reflowed every entry, including the
off-screen ones (14-16 ms per move in a long session). Entries now use
content-visibility: auto, so only the visible ones are laid out.
2026-10-28 19:04:18 +08:00
l0ng-ai 630d436e18 fix(editor): Allow selecting and copying text in read-only code previews (#136)
Code previews (Find in Files, Usages) are non-editable CodeMirror views, so their text inherited the workbench's user-select: none and had no tab stop: it couldn't be focused, selected with the keyboard or copied. Previews now opt their content back in with the shared selectable-text class and take a tab stop; editing stays blocked. Same root cause as the read-only diff panes (#126).
2026-10-28 18:32:00 +08:00
Dimas Victorтаdvictor357 a61a89fe86 fix(vcs): Allow selecting and copying read-only diff text (#126)
Read-only revision panes inherited the workbench's user-select: none and had no tab stop, so text couldn't be selected with the mouse or keyboard or copied. The viewer content now allows text selection and read-only panes take focus; editing stays blocked.

Once focusable, these panes picked up CodeMirror's dark-theme focused selection colour, which outranked the viewer's rule and made selected text unreadable in light themes; the viewer's selection rule now matches that specificity.

Co-authored-by: dvictor357 <[email protected]>
2026-10-28 18:21:18 +08:00
l0ng-ai 50212e7397 fix(agents): Don't paint a stray selection sliver after a list item's text (#134)
When a list item's text was followed by a nested list and a selection ended at
the end of that text, WebKit painted a thin strip of selection from there to the
right edge. The text of such items now gets its own block.
2026-10-28 18:15:09 +08:00
l0ng-ai 08fd134bdb fix(workbench): Keep tool window resizing smooth with a long agent conversation (#133)
Dragging a tool window edge set an inherited custom property on the tool window
grid, so every pointer move restyled the whole workbench below it. With a long
agent conversation open each move cost 150-400 ms. The grid track variables are
now registered as non-inherited, and the side splitter sets flex-grow on its two
areas instead of inherited variables on the side.
2026-10-28 17:59:09 +08:00
l0ng-ai ecb3134460 feat(agents): Show subagents as their own conversations (#132)
* feat(agents): Show subagents as their own conversations

Agents that support ACP subagent sessions (Claude Code, Codex) now run each
subagent as a child session. Its tool calls and messages no longer mix into
the task's conversation: the parent shows a card per subagent with its state,
tool call count, latest step, task and result, and the card opens the
subagent's own conversation.

- Route session/update by session id; subagent settings and usage stay out
  of the task.
- Subagent permission requests are answered in the task's conversation,
  named after the subagent.
- Subagent conversations are saved with the task; a running subagent is
  marked stopped after a restart.
- A running subagent can be stopped on its own.

* fix(agents): Save a subagent's state when it changes after the turn
2026-10-28 17:51:55 +08:00
l0ng-ai 5b63a2b95c fix(lsp): Drop language servers that leave the list from the status bar (#125)
Removing a language server (disabled in settings via stop_unlisted, or a fallback detached when its preferred server comes back) only emitted a stopped state, and the status bar list only refreshed for unknown servers, so the removed server stayed until the project was reopened. Both paths now go through forget(), which drops the server and emits IdeEvent::LanguageServerRemoved; the frontend drops the entry and ignores stale lsp_servers results that still contain it. Stopped servers no longer count towards "+N".

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-28 15:06:45 +08:00
胡翀таzzxzz12345 ac426c8881 feat(python): Add ty and select the Python language server (#121)
ty (`ty server`) joins basedpyright and Pyright as a Python language server (fallback, gets the project venv like the others, installable from Tools). Settings › Language Servers gets a Python language server picker: Automatic (first available), basedpyright, Pyright, ty or None, written through the per-server enabled settings (null globally / true in a project for enabled, false for disabled); other combinations show as Custom combination. Picker text is translated; the hint sits on its own line.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: zzxzz12345 <[email protected]>
2026-10-28 14:57:34 +08:00
l0ng-ai eef24ea785 fix(agents): Leave changes made before a local checkout task out of its changes (#123)
* fix(agents): Leave changes made before a local checkout task out of its changes

A task in the project's own checkout listed every uncommitted and untracked
file as its own. The files already changed when the task starts are now
recorded with their size and modification time, and drop out of the task's
changes until they are edited again.

* feat(agents): Show the current branch for a local checkout task
2026-10-28 14:55:10 +08:00
l0ng-ai f84ad80305 chore: v0.1.21 2026-10-28 14:45:11 +08:00
l0ng-ai 1f383c4e0e fix(app): Survive panics in background work and log them (#122)
* fix(app): Survive panics in background work and log them

A panic in background work (a spell check, an LSP handler) aborted the whole
app and left nothing behind: the message went to stderr, which a launched app
doesn't keep, and the stripped binary gave crash reports no function names.

- Release builds unwind, so a panic fails one task or command instead of
  quitting the IDE. Commands already turn a panicked blocking task into an
  error, and the existing catch_unwind around JSON-RPC notification handlers
  now takes effect.
- A panic hook appends the thread, location, message and backtrace to the
  log file synchronously, before the non-blocking writer could lose them.
- Release builds keep the symbol table (strip = "debuginfo"), so crash
  reports and logged backtraces name functions.

* test(app): Match the panic location with file!() so the test passes on Windows
2026-10-28 14:44:46 +08:00
Jaw 8ff8f22940 chore: Replace Prettier with oxfmt and ESLint with oxlint (#119)
pnpm lint runs oxlint and pnpm format runs oxfmt: lint ~0.3s instead of ~20s, format check ~0.2s instead of ~11s, with far less memory. Configs come from the official migration tools (.oxlintrc.json from eslint.config.js, .oxfmtrc.json from the Prettier config); six new findings fixed or suppressed in 4 files. No code is reformatted.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 14:19:38 +08:00
胡翀таzzxzz12345 a10e3c8898 fix(spell): Don't crash on Unicode punctuation before a word (#120)
known_run stepped one byte past the delimiter before a word run, which lands inside a multi-byte character (Chinese punctuation, emoji) and panics when slicing. It now steps past the whole character. Regression test with Chinese punctuation and emoji.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: zzxzz12345 <[email protected]>
2026-10-28 14:15:47 +08:00
l0ng-ai 96be40cd84 fix(process): Give child processes the host environment inside an AppImage (#118)
Fixes #117

Inside an AppImage, AppRun and the linuxdeploy GTK/GStreamer hooks point LD_LIBRARY_PATH, PATH, XDG_DATA_DIRS, GIO_MODULE_DIR, GTK_PATH, GST_PLUGIN_* … into the mounted image, and every child inherited them, so host programs loaded the image's older libraries (curl + bundled nghttp2 → symbol lookup error when installing Kotlin LSP). ide_core::process::prepare (formerly no_window) now gives children the host environment back: entries inside $APPDIR are dropped (a <NAME>_ORIG wins), emptied variables and the runtime's launch variables are removed, explicit call-site values are kept; the IDE process itself is unchanged. Covers all spawn sites that used no_window plus terminal/run PTYs, run configuration environments and stop commands, login-shell probes, attach ps/kill, docker PATH, tool search PATH and the Linux browser opener. No-op outside an AppImage. Not verified on a Linux AppImage.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-28 13:50:20 +08:00
l0ng-ai 429e8ffc82 fix(i18n): Translate the remaining Git and pull request text (#116)
Multi-repo branch popup matches actions by their translated label (highlights and Chinese search now work) and translates its group headers. Conflict counts, relative times (shared dateFormat), in-progress operation titles, git operation progress/results, branch widget tooltips, diff/hunk counts, worktree markers, PR author line and review placeholders go through tx; the default "Changes" changelist shows its translated name. HexViewer read-only leaves the untranslated allowlist. 92 zh entries.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-28 12:38:25 +08:00
l0ng-ai 443bb51bba refactor(agents): Drop the session cost from the context usage 2026-10-28 12:23:04 +08:00
Jaw 70f10e741c fix(i18n): Translate the remaining UI text (Git, hierarchy, pull requests, dialogs, aria labels) (#114)
Hard-coded UI text, titles and aria-labels in about 110 components (Git panels, hierarchy, pull requests, dialogs, settings, welcome) now go through tx; spliced fragments became whole sentences (fileCount, noneFoundLabel, hierarchyTitle) and pull request states use keyed messages. 225 zh entries added, stale fragment entries removed.

Also: tx used as a tag without interpolations (tx`No checks`) now looks the text up exactly instead of as a template, so it is translated like tx("No checks"); the text-catalog test checks such tags as plain literals. Check and test counts reuse the existing "{count} passed" / "{failed} failing" entries.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 12:18:46 +08:00
l0ng-ai faf666d125 chore: v0.1.20 2026-10-28 12:08:12 +08:00
l0ng-ai 93f87f49c1 feat(agents): Current branch and time spent in the agent session header
A local-checkout task shows the repository's current branch in its header, and
Changes compares against it; both follow branch switches. The header also shows
the time the agent has spent on the task's turns.

A usage update without a cost (Claude sends one for its rate limits) no longer
clears the session cost.
2026-10-28 11:57:18 +08:00
Jaw 6e4d927ff0 chore: Type-check with tsrs (#115)
pnpm typecheck runs tsrs (Rust port of the TypeScript 7 compiler) instead of tsc: ~1.4s instead of ~15s for the four projects, same diagnostics. typescript stays for the editor, typescript-eslint and the build.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 11:55:41 +08:00
l0ng-ai c77d1a6de7 fix(terminal): Keep the cursor to the font height instead of the whole line
With a line height above 1, xterm's DOM renderer stretched the cursor cell
over the full row. Size it to the font's own height and centre it with the glyphs.
2026-10-28 11:33:16 +08:00
l0ng-ai 0222c84ace feat(remote-dev): Remote Development in the project popup, opened in a new or this window
- The title bar project popup lists Remote Development… next to Open… and Clone Repository….
- Opening a remote project follows "Open project in" (new window / this window / ask) when this window already has a project, and confirms leaving the current one first.
- A remote project already open in another window focuses that window.
- A new window connects to the host itself and shows the connection progress on its welcome screen.
- Recent remote projects get Open in New Window.
2026-10-28 11:29:13 +08:00
l0ng-ai 45c7f43c02 fix(settings): Save SSH configurations with an empty port
An unset optional field arrives as null, which TOML cannot store; such fields are now left out when writing a settings value.
2026-10-28 11:29:13 +08:00
l0ng-ai 3a24597e58 feat(toolwindows): Agents second on the stripe, icon-only stripes by default
- Agents sits right after Project on the left stripe.
- The separator between a stripe's primary and split windows only shows
  while a button is dragged, as a drop target.
- "Show tool window names" is off by default: stripes start at their
  narrowest width with icons only.
2026-10-28 11:28:58 +08:00
l0ng-ai 75b5f6f972 Spell check: fewer false typos (programming terms, British spellings, possessives) (#113)
* feat(spell): Fewer false typos: programming terms, British spellings, possessives

- Bundle ~8.7k programming terms, tool and library names from cspell-dicts (MIT),
  plus a hand-kept list of tool names, file names and abbreviations. These pass on
  their own but never as part of an identifier compound.
- Accept en_GB spellings (colour, behaviour, normalise, cancelled) alongside en_US.
- Possessives of known words pass (backend's); programming terms take -ed / -ing /
  -able endings (stringified, memoizing).
- Plurals of terms no longer accept doubled or already-inflected forms (filess,
  contextes, checkings), and agent nouns aren't built from abbreviations (perfer).
- A word split from a known term is checked as the whole term (IntelliJ, PostgreSQL).
- Settings › Natural Languages lists English (USA, UK).

* chore: Regenerate bindings
2026-10-28 11:26:45 +08:00
l0ng-ai 44b021392a fix(agents): Show the title the agent gives the session
The task title starts as the first line of the prompt. When the agent sends
a session_info_update with a title, the task takes that title, saves it and
shows it in the Agents list.
2026-10-28 11:08:00 +08:00
l0ng-ai 415ade72e7 fix(agents): Compare a local-checkout task's changes against the current HEAD
Commits made in the project while a local task runs no longer show up in
its Changes pane, and Refresh lists what is actually uncommitted.
2026-10-28 11:06:54 +08:00
l0ng-ai 9ac4c379a4 feat(brand): New logo and app icon (#111)
Replace the mark, app icon, wordmarks and splash logo with the new
design: an orange-framed dark tile with a monospace "oi" on a light
rounded backdrop. Platform icons (png/icns/ico) are regenerated from
src-tauri/icons/source/openidea.svg.
2026-10-28 10:52:10 +08:00
l0ng-ai bb151b1d86 chore: rm useless files 2026-10-28 10:45:25 +08:00
l0ng-ai b3b4cbbe1a feat(settings): Group page description, collapsed page tree and a seamless title bar
- Group pages open with a short description and list their pages as
  indented links.
- The page tree starts collapsed, expanding only the current page's
  ancestors.
- The Settings window title bar sits on the dialog surface without a
  hairline.
2026-10-28 10:20:26 +08:00
l0ng-ai c6a4361513 fix(splash): Keep the chosen accent on the progress bar while loading
The bar read the theme's default --accent as soon as the tokens loaded,
so a custom accent flashed back to the default until settings arrived.
It now prefers the accent remembered from the last session.
2026-10-28 10:20:26 +08:00
l0ng-ai 01ecf9866e chore: v0.1.19 2026-10-28 09:59:05 +08:00
l0ng-ai 58dbb8eb1d Agents: select, copy, quote and search the conversation (#110)
* feat(agents): Select, copy, quote and search the conversation

- Conversation text can be selected and copied.
- Selected text gets a Quote button that replies to it in the composer.
- Messages have Copy on hover; your own messages also have Edit and
  Resend and Send Again.
- Code blocks are highlighted and have a Copy button.
- File paths in replies (`src/lib.rs:12`) open in the editor when the
  file exists.
- Find in Conversation (⌘F / Ctrl+F) highlights every match and steps
  through them, opening collapsed tool output.
- Jump to Bottom appears after scrolling up.
- Your messages show when they were sent and how long the agent took;
  a running turn shows its elapsed time.

* feat(agents): Quote a selection from the context menu

* feat(agents): Drop Jump to Bottom from the conversation

* feat(agents): Drop the hover Copy button from agent messages

* fix(agents): Highlight only the find matches on screen

WebKit paints a highlight range inside a collapsed tool output over the
text next to it. Matches in collapsed output still count, and stepping
to one expands it.

* fix(agents): Repaint find highlights as the search changes

WebKit doesn't repaint where a replaced highlight object was, so matches
of an earlier search stayed painted; each highlight is now one object
changed in place.
2026-10-28 09:58:50 +08:00
l0ng-ai 357b728f1d Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109)
* feat(editor): Drag tabs and files between split panes

HTML5 drag and drop never reaches the webview while native file drop is
enabled, so tab, project-file and changelist drags did nothing. They now
run on pointer events:

- Drag an editor tab onto another pane, or onto a pane's edge to split it.
- Drag files from the Project view into an editor pane (center opens them
  there, an edge opens them in a new split).
- Drag files between changelists in the Commit view.
- "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons.

* fix(lsp): Deliver server settings and start servers on a supported JDK

- workspace/configuration answers a server's own section (basedpyright)
  from its settings block; basedpyright runs in "standard" mode and keeps
  auto search paths on, so `src/` layouts resolve from `tests/`.
- jdtls: formatting follows the editor indent, calls complete without
  guessed arguments, and JDK internals stay out of completion.
- `max-java` in a language server config picks an installed JDK no newer
  than that (kotlin-language-server fails on JDK 25).
- Server initialization may take up to 5 minutes (first Gradle import).
- `lsp_resend_diagnostics` re-emits stored diagnostics after a reload.
- Spelling only checks declared names (not called methods like
  `substring`) and accepts plurals of known software terms.

* feat(editor): Quick fixes, completion and navigation while typing

Quick fixes
- Errors get a red quick-fix bulb; Apply Quick Fix (⌥⇧↵) fixes the most
  severe problem at the caret, and the error hover offers the fix directly.
- Code generated by a code action is re-indented to the file's style.
- Python: Implement abstract methods (⌃I or the bulb) from the bases.
- Duplicate code actions are listed once.

Completion
- Overloads sharing a name, and a name importable from several modules,
  are no longer merged into one entry.
- Auto-import entries show the module they import from.
- Call snippets without a stop (`new ArrayList<>()`) leave the caret inside
  the parentheses with parameter info, or past them when nothing takes
  arguments; parameter info also opens after server call snippets.
- The selected item is resolved ahead of acceptance, so its auto-import
  is applied (rust-analyzer).

Navigation and diagnostics
- Language server requests wait for pending edits, so Go to Declaration
  right after typing answers for the current text; an empty answer right
  after an edit is retried while the server catches up.
- Diagnostics come back after a window reload; error underlines stay
  visible under typo / unused highlighting.
- Implementation gutter markers no longer refresh in a loop while the
  server reports progress.
- A missing alternative server no longer shows as a server error.

* chore(lint): Skip agent worktrees under .claude

* fix(editor): Skip cancelled LSP requests and limit generated-code reformat

- Requests cancelled while waiting for pending edits are not sent.
- Code generated by an action is reformatted per changed hunk, so an added
  import doesn't reformat the code between it and the generated lines.
- A long busy spell caused by a settle refresh re-runs it only once.
- Files dropped on a file row of another changelist move to that changelist.
- Python Implement methods: a nested class without bases ends the lookup,
  class bodies continue through unindented string lines, and
  `@abstractproperty` stubs become `@property`.
- Resend diagnostics goes through the host ops, and custom servers get
  `max_java`.

* fix(editor): Rank the exact type first in Java constructor completion

jdtls appends the call or body to filterText (`String(${0})`); matching
on the label instead lets typing `String` rank `String` above
`StringBuilder` and the other types sharing its sort text.

* fix(editor): List identical completion items once

kotlin-language-server repeats a class once per classpath entry declaring
it, so `String()` and `String` were listed three times each.

* fix(editor): Keep the internal cancellation message out of the UI text catalog
2026-10-28 09:07:50 +08:00
l0ng-ai 48ac2094fc feat(lsp): Configure language servers and add servers of one's own (#108)
Language servers can be configured from settings.toml (global or project) and Settings › Language Servers: arguments, environment, initialization options and workspace settings per server, enable/disable, and any stdio LSP server can be added for a language. Settings-only changes are pushed with workspace/didChangeConfiguration; launch changes restart the server.

Also: re-enabling a server now detaches and stops the fallback that stood in for it, and a server added from the dialog is written as its own [languageServers.<name>] table.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-28 03:40:18 +08:00
l0ng-ai 025e0019d2 feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91) 2026-10-27 23:12:19 +08:00
l0ng-ai 8eb4834a48 fix(lsp): Make the language server tests deterministic (#107)
`ide-lsp` tests failed intermittently (about 1 run in 4, more under load).

The main cause, requests from the other side starting before earlier notifications, is fixed on main by b5bc150; this adds a regression test for it in `ide-jsonrpc`, documents that `on_notification` must not wait for the remote side, and drops the 200 ms polling workaround in `ide-agent` that papered over the same race for `session/request_permission`.

Test-only races fixed: mock replies are held with a gate instead of fixed sleeps and timing asserts; the indexing-retry test ends indexing after the first reply; the crash test counts Ready/Crashed transitions instead of reading the last status; the mock records a notification and applies its effect under one lock; `eventually` waits up to a deadline; `tcp_server` reserves its port with a bound socket and waits for the server's start marker.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 22:15:47 +08:00
l0ng-ai d7ff91760f fix(agents): Keep a deleted task from being saved again
A save that ran after delete (from the turn ending or the agent exiting)
wrote the task file back, so the task returned on the next load. Deleting
now marks the task under the lock that saving holds.
2026-10-27 22:03:24 +08:00
l0ng-ai 60f2f47547 ci: Cut redundant work from the CI run
- Save the Rust cache only on main; pull requests restore it.
- Check bindings against the file nextest's export_bindings already wrote
  instead of compiling the test again.
- Run e2e in its own job on the Playwright image, in parallel with the
  frontend checks, without installing browsers on each run.
- Cancel a run in progress when a newer push arrives, on main too.
2026-10-27 21:50:42 +08:00
l0ng-ai b5bc1501a0 fix(jsonrpc): Start incoming requests after the notifications that preceded them
Requests from the other side were handed to the blocking pool as soon as
they were read, while notifications run in order on their own thread, so a
request could be handled before an earlier didOpen. Requests now start from
the same ordered queue.
2026-10-27 21:38:42 +08:00
l0ng-ai cf421e4fcb chore: v0.1.18 2026-10-27 21:15:46 +08:00
itningтаitning 8d4c8885ca fix(diff): Jump to Source from a commit's file, next to Next Difference (#103)
Jump to Source moves next to Next Difference in the diff toolbar, with the edit (pencil) icon. Diffs where neither side is a file on disk (Git Log commit files, Compare Branches, multi-commit diffs) now offer it when the path still exists in the working copy; files deleted by the commit don't.

Jumping from a pane that isn't the opened file used the other pane's caret. The caret line is now mapped through the diff to the target (the other pane's document, or the working copy read at click time including unsaved edits); the column is kept only when the line is unchanged.

Co-authored-by: itning <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 21:10:53 +08:00
l0ng-ai 2fe6b7b87b feat(editor): Implemented / overridden gutter markers (#106)
Adds gutter markers for type hierarchies: ↓ "Is implemented / Has subclasses / Is overridden" on types and methods with implementations, ↑ "Implements / Overrides method in X" on methods that implement or override a supertype method. One target navigates, several open the Choose Implementation popup. Settings › Editor › General › Gutter Icons gets two toggles.

Markers come from the language server: a new batched `lsp_implementations` command (up to 4 concurrent, cancellable, sharing the goto query path) for ↓, and the Super Method lookup for ↑, which now prefers the overload with matching parameters (also fixes ⌘U on overloads). Only declarations near the viewport are queried, debounced, cached per document version and cancelled when superseded. Works for any server with implementation / type hierarchy support; servers without type hierarchy show ↓ only.

Fixes #99

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 21:02:54 +08:00
itningтаitning 6de6b33449 feat(vcs): Double click switches the log and Cmd/Ctrl+C copies branches in Git Log (#101)
In Git Log's Branches pane a single click filtered the log and a double click checked the branch out. Now a click only selects; double click or Enter shows the log of that ref (also the current branch and HEAD), and Enter on a multi-selection filters by all selected branches in tree order. Checkout stays in the context menu. The pane keeps its own selection and follows filter changes made elsewhere. Cmd/Ctrl+C copies the selected branches as full refs, one per line (HEAD as `HEAD`), leaving copy in the search field and elsewhere untouched.

Co-authored-by: itning <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 21:02:10 +08:00
l0ng-ai c3b64304ac fix(agents): Offer project skills and commands in the composer (#105)
The new-task composer only offered the slash commands the agent sent in its last session, stored once globally, so a project that hadn't run a session (or whose last session ran elsewhere) showed none of its skills; matching was substring-only, so `/trig` didn't find `/triage` either.

Offered commands are now remembered per project root. For Claude Code, skills (`.claude/skills/*/SKILL.md`) and commands (`.claude/commands/**/*.md`) are also discovered on disk, from the working directory up to home plus `~/.claude`, and merged with the agent's list (agent entries win). New `agent_commands` IPC serves the home composer (project root) and session composer (the task's cwd). Matching ranks prefix (also after `plugin:`), then substring, then subsequence. Picking a completion inserts `/name ` and the prompt is sent unchanged.

Not covered: a new worktree of a project that gitignores `.claude/` doesn't contain the project's skills.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 21:01:11 +08:00
l0ng-ai 14f72fc307 feat(http-client): Generate requests from Spring Web mappings (#104)
Spring MVC/WebFlux mapping methods (Java and Kotlin) in @RestController / @Controller classes get a gutter icon with "Generate Request in HTTP Client", and the class line gets "Generate Requests for Controller"; both are also actions (`http.generateSpringRequest`, `http.generateSpringControllerRequests`) in Find Action, the editor menu and ⌥↵. Requests are appended to the `generated-requests.http` scratch and opened at the new request.

The URL combines class and method paths ({id} and {id:regex} → {{id}}), with host, port and context path from application.properties / .yml (default http://localhost:8080). @RequestParam, @RequestHeader, params/headers, consumes/produces and @RequestBody (JSON skeleton from the body type, nested one level) are mapped; same-file constants are resolved. Adds an `intentionProviders` editor facet so features can contribute ⌥↵ items.

Fixes #100

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 20:54:48 +08:00
l0ng-ai a065189516 fix(lsp): Launch jdtls with java and surface output outside the protocol (#102)
On Windows the auto-downloaded jdtls ran through `bin\jdtls.bat`, which starts the Python wrapper and ends with `pause`. When Python failed, `pause` wrote its prompt to stdout in the console code page; the transport treated that as a broken LSP header ("non-UTF-8 header"), dropped it and the server was killed, so the status only said "exited with code 1".

- jdtls is now launched with `java` directly on every platform (Equinox launcher, platform config dir, JDK 24+ flags, forwarded JVM args, `-data`), so Python is no longer needed; user wrappers and unusual layouts keep their command.
- Output outside the protocol on stdout (and non-UTF-8 stderr) is decoded (UTF-8, then the Windows OEM/ANSI code page, then lossy), logged and included in the crash message, for stdio and TCP language servers, DAP adapters and ACP agents.
- Crash messages carry the real exit code and the last lines of output.

未在 Windows 实机验证 (Windows paths covered by unit tests and a Windows-target clippy run).

Fixes #90

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 20:44:09 +08:00
l0ng-ai 9258185517 fix(ui): Keep focus rings off text fields regardless of role
Search Everywhere's query input carries role="combobox", so the global
focus-ring rule drew an outline inside its frame. Text inputs are now
excluded from that rule, and the selector list uses :where() so its
specificity matches a bare :focus-visible and component focus styles
always take precedence.
2026-10-27 20:43:51 +08:00
l0ng-ai 8568ff001b fix(i18n): Translate interpolated UI text through a tx template tag (#98)
About 270 UI strings were JS template literals with interpolation passed to exact catalog lookup, so they could never be translated; about 25 more spliced lowercase English fragments into sentences.

`tx` now also works as a template tag (`` tx`Remove data source '${name}'?` ``): it looks up the entry by the literal parts (`{name}` placeholders), translates interpolated values that have entries, and remembers generated text so it re-translates after a language switch or when the catalog loads later. Call sites use the tag; spliced fragments are written as whole sentences per variant. Adds 280 Chinese translations and removes stale ones.

The catalog guard now also fails on untagged template literals reaching exact lookup (unless allowlisted with a reason), tagged text missing from the catalog, case-changed or ternary-suffix interpolations, and duplicate template patterns.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:56:54 +08:00
胡翀таzzxzz12345 2a8e5e791d fix(diff): Keep distant edits as separate chunks and render continuous change blocks (#95)
In long files several distant edits could collapse into one large replacement that highlighted unchanged lines, and multi-line changes showed per-line stripes.

Lines are now matched before character-level changes. The line diff is anchored on lines that occur once on both sides (patience-style), with a bounded diff only between anchors, so 50k-line files with distant edits keep separate chunks. Whole inserted or deleted lines render as continuous blocks; paired modified lines keep the line background plus word-level highlights, also inside multi-line chunks. Line decorations are built for the visible range only.

Co-authored-by: zzxzz12345 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:54:15 +08:00
Walter WangтаClaude 3b608355a3 feat(themes): Built-in Solarized and One light/dark themes (#85)
Adds Solarized Light, Solarized Dark, One Light and One Dark as built-in themes across the workbench, editor, terminal and Mermaid, keeping the original palettes (MIT notices in THIRD_PARTY_LICENSES.md, mapping in docs/themes.md).

- Native window colours are read from tokens.css at build time (`theme_frame.rs`) instead of a second colour table.
- Startup restores the built-in palette before first paint.
- Editor schemes, the terminal and Mermaid refresh on switches between themes of the same kind.
- The plugin packages of the same themes are removed.
- A test requires every built-in palette theme to define each token (only listed ones may inherit from the base theme), so new tokens can't silently miss them.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:53:12 +08:00
l0ng-ai 50308a956d feat(maven): Dependency Analyzer with conflicts, usage paths and Exclude (#97)
Maven sync ran a plain `dependency:tree`, which drops the nodes Maven omits (conflict losers, duplicates), so there was no data to show where a conflict comes from.

Sync now runs `maven-dependency-plugin:3.8.1:tree -Dverbose` (the project's own plugin version when offline) and records omitted / conflictWith / managedFrom per node; omitted nodes stay out of the Maven tool window and External Libraries, and older caches are marked stale. The new Dependency Analyzer (Maven toolbar, module / Dependencies node menus, pom.xml menus, Find Action) lists a module's resolved artifacts or tree with conflict markers, search and scope filters, and every path to the selected artifact with its status. Exclude writes an `<exclusion>` into the declaring pom (module or in-project parent), keeping indentation and line endings, then reloads; Jump to Source and Copy Coordinates are on each path. Gradle is not covered yet.

Fixes #87

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:51:33 +08:00
l0ng-ai c666cee075 feat(project-tree): Expand directories with a single click (#96)
Adds "Expand Directories with Single Click" to the Project view options (Behavior group, off by default). When on, a plain left click anywhere on a directory row expands or collapses it (a compacted path opens its whole chain); a double-click toggles once, and modifier clicks, drag-and-drop, inline rename and the context menu are unaffected. Project view option toggles now show their checked state in Find Action, and the options menu is translated.

Fixes #94

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:43:39 +08:00
l0ng-ai a971483162 fix(ci): Drop a stale Chinese catalog entry and unix-only imports on Windows
The Solarized dark variant's description no longer appears in the source, so the
catalog check failed on its leftover translation. LaunchError and Launcher are
only used by the unix-only tcp_server tests and were unused imports on Windows.
2026-10-27 18:17:06 +08:00
l0ng-ai 2616e3fe6d fix(i18n): Translate missing Chinese UI text and guard the text catalog (#93)
The source-text catalog (locales/text/zh.json) is keyed by the English text, so reworded or new UI strings silently fell back to English (e.g. the language server JDK hint on the Java settings page). Adds the 321 missing Chinese translations, updates the 3 reworded keys, removes 45 stale keys, and makes the file-refactoring failure titles translatable.

New guard test (src/platform/locales/textCatalog.test.ts) parses src/ for literals that reach the translation layer and fails on missing or stale catalog entries, with an explicit allowlist for untranslated text.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 18:04:35 +08:00
l0ng-ai b5bd8e3935 chore: v0.1.17 2026-10-27 17:58:55 +08:00
l0ng-aiтаbghtyu 24330192c0 feat(themes): Solarized and One theme plugins (#92)
Solarized Light/Dark and One Light/Dark ship as theme plugins (plugins/theme-solarized, plugins/theme-one) with complete UI tokens, editor schemes and terminal colours, and the original palettes' MIT notices. Installing a plugin now also copies its LICENSE/NOTICE/COPYING.

Theme handling that benefits every plugin theme:
- The last plugin theme is restored before first paint (splash, workbench); the native window background follows the theme.
- HSL / color-mix() / var() colours are resolved to RGB for native windows, the Settings window and Mermaid.
- Mermaid, the terminal and editor schemes follow switches between two themes of the same kind.
- An explicitly chosen editor scheme wins over a plugin theme's editor colours.

Supersedes #85.

Co-authored-by: bghtyu <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 17:53:07 +08:00
l0ng-ai 9d8280104f fix(agents): Run Codex through @agentclientprotocol/codex-acp
@zed-industries/codex-acp is archived; its last release (0.16.0) bundles an
older Codex core that doesn't know newer models, so a config.toml model such as
gpt-6.1-sol fell back to generic metadata with a warning. The maintained adapter,
@agentclientprotocol/codex-acp, runs the Codex App Server from a current
@openai/codex and lists the account's models.
2026-10-27 17:44:04 +08:00
l0ng-ai 8ec35c94ec fix(lsp): Find usages in unopened files outside the language server's scope (#89)
Reference-based features only asked the language server, which analyses its configured scope (e.g. Pyright's `include`) plus open files, so usages in unopened files outside that scope were silently missing.

Find Usages, Show Usages, usage counts, Safe Delete of a symbol, Rename and incoming calls now also search project files that contain the symbol's name (whole word, same language, .gitignore/excludes respected, unsaved edits included, up to 300 files, cancellable). Candidates the first answer didn't cover are opened in the server temporarily, queried and closed again. Files the user has open are never touched, and diagnostics for the temporary files are dropped. An empty result while the server is still indexing is retried once indexing ends. When results may still be incomplete (indexing, file cap), the usages UI says so. `lsp_references` takes a RequestId and is cancelled by newer searches. Not covered yet: the Safe Delete scan when deleting a file or directory.

Fixes #83

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 17:41:20 +08:00
l0ng-ai 482f391fd1 fix(splash): Draw the wordmark as outlines so it can't swap fonts
The splash set its wordmark in Inter, a web font with font-display: swap. When
the font arrived late (on a cold dev start the dev server took up to seconds to
serve it), the splash showed the system font first and switched to Inter just
before fading, so the word grew and shifted. It is now an inline SVG of the same
text in Inter 500, so it renders identically from the first frame. This replaces
the previous attempt, which only moved where the font faces load.
2026-10-27 17:35:37 +08:00
l0ng-ai 866af3d9cb fix(agents): Give child processes the login shell's environment
An app started from Finder or the Dock took only PATH from the login shell, so
variables exported in rc files never reached agents, language servers, run
configurations or debug targets. Claude Code configured for Bedrock through
CLAUDE_CODE_USE_BEDROCK / AWS_REGION in ~/.zshrc fell back to the Anthropic API
and failed with 403.

The shell's environment is read once at startup (in the background when PATH
comes from the cache) and kept in memory only, since it may hold secrets. Spawns
add the variables this process lacks before their own.
2026-10-27 17:16:47 +08:00
l0ng-ai 145f168525 fix(agents): Composer input-method Enter, autocorrect and task header layout
- Enter that commits an input-method candidate no longer sends the message.
- The composer turns off autocorrect, autocapitalisation and spellcheck, which
  popped up correction bubbles over prompts.
- The provider icon in the task header sits inline with the details instead of
  on its own line above them.
2026-10-27 17:16:46 +08:00
l0ng-ai f73c0a6b1d fix(keymap): Don't run the Enter that commits an input-method candidate
WebKit sends the Enter (or Space) that commits a candidate after compositionend,
with isComposing already false and keyCode 229. Handlers that checked isComposing
alone ran it: dialogs confirmed and shortcuts fired while typing Chinese or
Japanese. isImeKey() checks both, and every keydown handler that skipped
composition uses it.
2026-10-27 17:16:46 +08:00
l0ng-ai 2c576418cb fix(splash): Keep the wordmark still until the splash fades
The splash named Inter, but the font faces and antialiasing came with the app CSS,
which the dev server injects only once the bundle runs: the wordmark switched from
the system font to Inter, moving up and changing weight, just before the fade. The
font faces are now linked from index.html and the splash sets its own smoothing.
The splash also stays on one compositing layer, so the fade-out doesn't re-snap
the centred text.
2026-10-27 17:16:46 +08:00
l0ng-ai b70d487a92 fix(ui): Don't draw a second focus box inside framed text fields
The global focus ring listed input and textarea. :is() takes its most specific
argument, so the rule tied with the components' own :focus-visible resets and won
whenever it loaded later: the Settings search field and the agent composer showed
an inner square box inside their focus frame. Text fields show focus with their
frame; the ring stays on checkboxes, radios and other input controls.
2026-10-27 17:16:46 +08:00
胡翀таzzxzz12345 413564efcf feat(editor): Increase / Decrease Font Size, followed by the Markdown preview (#81)
Adds Increase Font Size / Decrease Font Size (⌥⇧. / ⌥⇧, in the IntelliJ keymaps) for the active editor, sharing the ±1px step and limits with ⌘/Ctrl + mouse wheel. The Markdown preview, including code blocks, follows its editor's zoom, and Reset Font Size in All Editors resets it too.

Editor zoom had no effect once a font size was configured in settings: the configured size was applied with higher precedence than the zoom. The zoom now wins.

Co-authored-by: zzxzz12345 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 16:52:25 +08:00
l0ng-ai f675dd2730 feat(vcs): Repository list in the Branches popup for multi-repository projects (#84)
In projects with several repositories the Branches popup began with a wrapping row of repository chips and listed only the selected repository's branches, and the widget always read "repo / branch".

The popup now lists one row per repository (colour, name, current branch) whose submenu holds that repository's branches. When all repositories are on the same branch the widget shows just the branch and the popup offers Common Local / Remote Branches whose actions apply to every repository; otherwise it shows the current repository's Recent / Local / Remote Branches and Tags. The widget and the popup use the same current repository: the one owning the active file, else the selected one. Search covers actions, repositories and branches across repositories. New Branch… creates the branch in all repositories. Single-repository projects are unchanged. `vcs_roots` now returns each repository's branch and HEAD.

Fixes #78

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 16:51:50 +08:00
l0ng-ai 4100ab3341 feat(markdown): Render Mermaid diagrams in the preview (#82)
Fenced ```mermaid blocks in the Markdown preview render as diagrams. mermaid (12.1.0) is loaded lazily, only when a preview contains a diagram, with securityLevel "strict"; colours come from the theme tokens and diagrams re-render on theme changes. Syntax errors show inline under the source. Rendered diagrams are cached by theme + source, so editing elsewhere in the file doesn't redraw them.

Fixes #79

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 16:23:35 +08:00
胡翀таzzxzz12345 fdd98fbfe4 feat(editor): Navigate inferred type inlay hints (#77)
Some language servers (basedpyright) send inferred type hints as plain text without a label-part location, so ⌘/Ctrl-clicking `: Series` did nothing. For a type hint right after an identifier, ⌘/Ctrl-click now goes to that identifier's type declaration; hints with locations keep direct navigation. Navigable hints show the link underline and pointer only while ⌘/Ctrl is held, including when it is pressed before hovering.

Co-authored-by: zzxzz12345 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 16:10:19 +08:00
l0ng-ai 5d2c89b0dd chore: v0.1.16 (#80)
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 15:36:39 +08:00
l0ng-ai d3f68ed180 fix(keymap): Navigate Back / Forward with the mouse side buttons (#76)
Keymaps only understood keystrokes, so the mouse back / forward side buttons did nothing. Keymaps now accept mouse shortcuts (Button4, Shift+Button5, …); every preset binds Button4 / Button5 to Navigate Back / Forward. The dispatcher handles side-button presses in the capture phase, so they work over the editor, tool windows and empty space, never change the editor selection and don't trigger the webview's own history navigation. Menus and tooltips keep showing the keyboard shortcut.

Fixes #75

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 15:34:54 +08:00
l0ng-ai e3dae02de7 feat(languages): Add Godot / GDScript support (#73)
Adds GDScript, Godot resource and shader file types with tree-sitter highlighting, the GDScript language server over TCP (a headless Godot editor started on demand), "Godot Project" / current-scene run configurations, and debugging through Godot's debug adapter.

Breakpoints set through symlinked paths (/tmp, /var on macOS) were reported verified but never hit; the adapter now sends resolved paths and maps stack frames back to the paths the IDE opened. The duplicate exit event Godot sends after disconnect is ignored.

Fixes #68

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 15:31:25 +08:00
l0ng-ai 2eaa6cb0c7 feat(agents): Dispatch and supervise coding agents over ACP (#72)
## What

An Agents feature: hand a task to a coding agent and follow it from the IDE. Agents speak the [Agent Client Protocol](https://agentclientprotocol.com). Built in: Claude Code, Codex, Gemini CLI, GitHub Copilot, Cursor, OpenCode, Qwen Code, Kimi CLI, Goose, Auggie, Kiro, Mistral Vibe. You can add your own in `<config>/agents/<id>.toml` (`name`, `command`, `args`, `[env]`). An agent whose command isn't on `PATH` is listed under "Not installed".

- **New Task page.** An editor page with a prompt box. You pick the agent, model, mode and thinking effort, and whether it runs in a new worktree (from a chosen branch) or in the checkout.
- **Agents tool window.** Tasks are grouped by what they need: Needs Input, Failed, Running, Ready to Review, Archived. It also shows a stripe badge, a status bar item and notifications.
- **Session tab.**
  - The conversation: Markdown, thinking, tool calls with diff stats, the plan.
  - Permission requests appear as an editor banner.
  - The changed files are compared against the task's base commit and open in the diff viewer.
  - A context-usage meter.
- **Composer.**
  - `@` references files from the project index. A referenced file's text is sent when the agent accepts embedded context, otherwise a `resource_link`.
  - `/` lists the agent's commands; ↑/↓ recall earlier messages.
  - Images can be pasted, dropped or picked; Shift+Tab cycles the mode.
  - While the agent works, Enter queues the message and ⌘↵ interrupts the turn and sends it now. Queued messages can be sent now, edited or removed.
- **Send to Agent.** In the editor and Project view context menus. It adds `@file:10-20` references to the message you typed last.
- Landing the work is left to the existing Git features: the worktree is a normal branch.

## How

- **`crates/ide-agent`**: one ACP agent process per task, running in the task's directory.
  - It folds `session/update` into a timeline and answers `session/request_permission`.
  - It tracks the session's settings: `configOptions`, falling back to the older `modes` / `models` fields. The user's choices are applied again on every new or reloaded session.
  - Tasks are saved as JSON per task. Sessions resume with `session/load` without duplicating history, and old task files are upgraded when read.
- **`ide-jsonrpc`**: adds newline-delimited framing (ACP) next to Content-Length (LSP).
- **`ide-vcs`**: adds `numstat_with_worktree` for the changes list.
- **Editor**:
  - The Switcher (⌃⇥) lists every open editor, as IDEA does, not just files.
  - `EditorKindContribution.restorable` lets a kind's tabs be saved with the editor session, so agent tabs survive reloads.
- **UI**: `Select` popups now size to fit short option details (they used to cut them off).

## Testing

- `cargo test -p ide-agent` (14 tests). They use an in-memory ACP agent and cover permissions, restart with `session/load`, settings, the queue with send-now and stop, and prompt blocks.
- There is also an ignored end-to-end test against the real Claude Code adapter: `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored`.
- `pnpm vitest run`: 2052 tests.
- `cargo clippy -p ide-agent -p openidea --all-targets -- -D warnings` and typecheck are clean.
- Driven in the running app with Claude Code:
  - A task in a worktree, with permission answers, changes listed and the diff opened.
  - A follow-up after restarting the IDE.
  - `@` reference with embedded content, `/` commands, Shift+Tab mode changes.
  - A queued message and send-now, an image prompt, Send to Agent from a selection.
  - The Switcher and restored tabs after a reload.

Not covered yet: only Claude Code was driven end to end. The other agents go through the same ACP path but weren't run here.


Merged on the maintainer's go-ahead. Merge with main: kept both sides in Cargo.toml (ide-agent + ide-clipboard), the feature registry (agents + vim) and zh.json (no duplicate keys). After the merge: typecheck clean, full vitest 268 files / 2247 tests passed, ide-agent/ide-jsonrpc/ide-vcs 144 passed, bindings unchanged after regeneration.
2026-10-27 14:44:28 +08:00
胡翀 b4742b730b Support custom editor font weights in steps of 50 (#74)
JetBrains Mono at 500 looks heavier than PyCharm's rendering, and 450 was rounded up to 500. Adds a Custom option with an input to the Main and Bold font weight rows; weights are clamped to 100–900 and rounded to the nearest 50 (the terminal, which follows the editor weights, gets the same steps).

Verified in the app: Custom → 450 shows the input and the preview renders at 450. Maintainer follow-up: formatted the dialog test.

Related: #32

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 14:38:42 +08:00
胡翀 566b4fdeb1 Make static symbol italics configurable (#70)
Python language servers mark class attributes `static`, so their declarations and `self.` references rendered italic, unlike PyCharm. Adds Color Scheme attributes for static symbols: Language Defaults › Identifiers › Static symbol (italic by default, as IDEA's static members) and Python › Static symbol (upright by default), applied to semantic tokens with the `static` modifier.

Verified in the app with basedpyright: a class attribute renders upright by default and italic when Python › Static symbol › Italic is on, applied live. Maintainer follow-up: formatted colorScheme.tsx.

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 14:27:24 +08:00
l0ng-ai 12418e0184 fix(editor): Show the ⌘/Ctrl-hover link without waiting for documentation (#71)
## Problem
When you hold ⌘ (Ctrl on Windows/Linux) and hover a symbol, the navigation link had three problems:
- **Slow to appear.** The underline and pointer cursor only showed after the hover (documentation) request came back. A slow or stuck documentation request held the link back.
- **Errors were remembered.** If the definition request was cancelled or the server returned an error, the word was cached as having no target. Hovering it again never retried.
- **Stuck requests blocked the word.** A definition request that never answered blocked any new request for that word.

## Changes (`src/editor/lsp/navigation.ts`)
- The link is underlined as soon as a definition is found. The short summary is filled in when the hover request returns.
- Cancellation and server errors count as temporary failures. They are no longer cached, so the next hover retries.
- A definition request expires after 3 seconds and can be retried. If its response arrives later, it is ignored.
- Responses for a word the pointer has already left, or that arrive after the editor plugin is destroyed, are dropped.

## Tests
- Four new tests in `src/editor/lsp/syntaxFallback.test.ts`: the link shows without waiting for documentation, a temporary error is retried, a stuck request is retried and its late answer ignored, and a late answer for the previous word is ignored. The first three fail without this change.
- `pnpm vitest run src/editor/lsp`: 179 passed. `pnpm typecheck` and eslint report no errors.
- Checked in the running app (`fixtures/ts-app`, `cart.ts`): ⌘-hover over `Cents` showed the underline and pointer cursor after 312 ms and the type summary after 356 ms. ⌘-click opened `money.ts` at the definition.
2026-10-27 14:23:23 +08:00
l0ng-ai 7f0fd870e8 fix(themes): Define the chart token in the high-contrast dark theme (#69)
contrast.test.ts requires every plain colour token in high-contrast dark; #63 added
--chart-series to the dark and light themes only.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:46:12 +08:00
l0ng-ai 575749f2ac feat(database): Add query plans, history, data tools and schema objects (#63)
New query, data and schema tools for the Database feature (SQLite, PostgreSQL, MySQL).

## Query console
- **Explain Plan** tab: SQLite `EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN (FORMAT JSON)`, MySQL `EXPLAIN`, parsed in `ide-db::plan` into an operation tree (Full Scan / Index Scan / Group By / Order By …) under a statement root, with a properties panel.
- **Query history**: `ide-db::history` keeps executed statements per project and source in `db-history.json` (deduplicated, newest first, 500 max). The Browse Query History dialog has search, a highlighted preview, Paste and Delete.
- Output reports `(execution: X ms, fetching: Y ms)`.

## Results and table editor
- Local column filter (value list with counts) and local sort.
- Record / Value / Aggregates side panel.
- Chart view (bar / line) with X / Y / aggregation settings.
- Export Data dialog: extractor, Transpose, headers, preview, file or clipboard. An extractor drop-down sits on the data toolbars.
- Import Data from a CSV / TSV file.
- Table editor gets a WHERE | ORDER BY row. Column headers fit their names, with filter and sort indicators. Whole SQLite REALs display as `3108`, not `3108.0`.

## Database tree
- Triggers under tables. Routines, sequences and materialized views under schemas, with DDL, Quick Documentation and Drop.
- Data source options: object filter, show template databases, single database mode (PostgreSQL).
- New ▸ Import from Clipboard (connection URLs).

## IPC
New commands: `db_schema_objects`, `db_object_ddl`, `db_explain`, `db_history`, `db_remove_history`. Bindings are regenerated.

## Testing
- `cargo test -p ide-db`: plan parsing, history store, trigger listing and DDL, explain without executing.
- `cargo clippy -p ide-db -p openidea --all-targets -- -D warnings` is clean.
- `pnpm typecheck` and eslint pass. `pnpm vitest run src/features/database src/ui`: 226 passed.
- Checked in the running app against a SQLite database:
  - tree with triggers
  - result grid, local filter, side panel
  - chart
  - Explain Plan
  - history dialog
  - Export Data dialog
  - table editor ORDER BY
  - CSV import




Merge follow-up: grouped the new `--chart-series` token under a section comment and added it to the theme plugin template (theme.test.ts on main), regenerated docs/plugins/themes.md. Re-verified after merging main on macOS with a SQLite source: query + result grid with filter/sort headers, Explain Plan tab (Full Scan / Index Scan / Group By / Order By), Query History dialog; `cargo nextest run -p ide-db` 45 passed, bindings unchanged after regeneration.

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:31:29 +08:00
l0ng-ai 580f0efc28 fix(ui): Keep the Select chevron at the right edge with a custom value (#67)
Select only wrapped its default label in the flex-filling value span, so a
renderValue (the data source Color picker's swatch + name) left the chevron
right after the text, short of the field's edge.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:20:43 +08:00
l0ng-ai dcdb0e48e4 fix(ui): Draw focus rings on controls only, not on focusable containers (#66)
The global :focus-visible outline loads after the module CSS with the same
specificity, so every focusable container without its own override (the
Intentions list, other list/tree/menu popups) got a square blue frame. ee99b36
patched the Select popup only. Like IDEA, rings now mark controls (buttons,
links, fields, checkboxes, tabs…); lists, trees, tables and popups show focus
through their selected row, and the few rows that drew frames use the hover
background instead.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:09:29 +08:00
l0ng-ai 17400ade3d test(e2e): Reach later Settings groups in the virtualized page tree (#65)
The Settings page tree only renders rows near the viewport. Editor › Vim (#55) pushed
Version Control past the rendered rows on CI's Linux runner, so the e2e failed on main.
Collapse Editor before checking the later groups.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:00:37 +08:00
l0ng-ai b91402ef7d chore: v0.1.15
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 12:10:30 +08:00
l0ng-ai 56fdc005a8 feat(java): Download JDK, and one-click "Download JDK N" where a JDK is missing (#61)
Root cause: the IDE only detected installed JDKs and had no way to get one, so any launch needing a newer JDK than the machine has (latest jdtls needs JDK 25) dead-ended in an error.

Fix: IDEA-style Download JDK (Eclipse Temurin via the Adoptium API, sha256-verified, reusing ide-tools' download/verify code; Windows zip, Linux tar.gz, macOS Contents/Home) into <app-data>/tools/jdks, registered with JDK discovery and rescanned after each download. "Download JDK…" in the Project SDK and Language Server JDK pickers; a one-click `java.downloadJdk` "Download JDK N" on "needs JDK N" errors, which also repoints a too-old language server JDK and restarts jdtls.

Siblings: language server status popup, jdtls launch notification, run console classpath error, debug launch, run configurations without a JDK ≥ the module's Java version, Maven/Gradle sync failures, single-file Java run without `java` on PATH.

Verified in the app on macOS: vendor/version/location queries, the Download JDK dialog from the Language Server JDK picker, a real Temurin 25.0.5 download that runs. Not verified on Windows/Linux.

Fixes #49

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 12:09:51 +08:00
l0ng-ai beab7992f9 feat(editor): Rainbow brackets and semantic highlighting (rainbow variables) (#59)
Two editor toggles under Settings › Editor › Color Scheme › Language Defaults (off by default, also actions `editor.toggleRainbowBrackets` / `editor.toggleSemanticHighlighting`):
- Semantic highlighting (IDEA's rainbow variables): locals and parameters coloured by a name hash from LSP semantic tokens; fields, statics, constants, library symbols untouched.
- Rainbow brackets (VS Code's bracket pair colorization): (), [], {} by depth, unmatched in the error colour, strings/comments skipped, visible ranges only with 4 KB checkpoints.
Palettes are theme tokens (`--syn-rainbow-1..5`, `--syn-bracket-1..3`, light/dark/high-contrast), editable in the Color Scheme tree, and mapped from IntelliJ `.icls` RAINBOW_COLOR1..5.

App verification on macOS found the colours were overridden by the syntax highlighter's span nested inside the marks; the rules now cover descendants. Verified both toggles on/off live in a TS file.

Fixes #56

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 11:52:07 +08:00
l0ng-ai 2e0c74e31d docs(themes): Regenerate the theme token reference (#62)
Main's Frontend CI failed: docs/plugins/themes.md was stale after a token change.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 11:47:39 +08:00
l0ng-ai e16253a890 fix(editor): Ctrl/⌘-hover info replaces hover popups instead of stacking (#60)
Root cause: the quick-doc/problem hover and the Ctrl/⌘-hover navigation info were independent tooltips with no precedence, and the navigation modifier was only seen through the focused editor's key events, so holding Ctrl stacked both popups.

Fix: a window-wide platform navigation modifier (`src/platform/navigationModifier.ts`, ⌘ on macOS / Ctrl elsewhere, unchorded); with it held the plain hover doesn't open, late LSP results are dropped, an open hover closes (unless the pointer is inside it, so Ctrl/⌘+C still copies), release clears the link and info.

Siblings: problem/diagnostic hover, debugger value tooltip, inlay hint hovers (no doc or underline through hints, platform-correct Ctrl/⌘-click), consistent ⌘ vs ⌃ handling on macOS.

Verified in the app on macOS (⌘): hover then ⌘ leaves only the nav info, ⌘ before hover shows only nav info, release clears both and plain hover returns. Not verified on Windows.

Fixes #57

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 11:46:04 +08:00
l0ng-ai 7146779f6c feat(vim): Built-in Vim mode modelled on IdeaVim (#55)
Built-in Vim emulation on @replit/codemirror-vim (approved dependency), modelled on IdeaVim: `vim.toggle` (Tools › Vim, status widget, Settings › Editor › Vim), mode widget (NORMAL / INSERT / VISUAL / V-LINE / V-BLOCK / REPLACE), IdeaVim-style shortcut conflict handlers (Vim / IDE per key) through a generic `registerKeyClaim()` in the keymap dispatcher, Escape closing popups first, a safe subset of ~/.openideavimrc / ~/.ideavimrc (set, map variants, mapleader, :action / <Action>()), Ex commands :w :wa :q :qa :wq :x :action. Covers file editors, diff editable side and merge result via a new `embeddedEditorExtensions` extension point.

Verified in the app on macOS: motions/operators, i/Esc/V mode widget, :w saves, Ctrl+R redoes in Vim without running Run, toggling off restores plain editing. Not verified on Windows/Linux.

Fixes #27

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 11:43:37 +08:00
l0ng-ai cac95273d3 fix(java): Detect Lombok inherited from outside the project (#58)
Root cause: `uses_lombok` only scanned build files ~3 levels below the jdtls root, so Lombok inherited from outside that tree was judged `Unused` and no `-javaagent` was attached — even when Lombok was installed in Settings.

Covered: parent pom only in the local Maven repository (settings.xml localRepository honoured, multi-hop, cycle-safe), parent via relativePath outside the workspace, outer Gradle root build / version catalog, `lombok.config` in ancestor directories, and a bounded source fallback (`import lombok.` / `@lombok.`) for convention plugins and BOMs. A Lombok jar installed in Settings is now always attached (like IDEA's always-on Lombok support). Detection in `java_resolve_launch` runs in `spawn_blocking`.

Fixes #33

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 11:19:45 +08:00
胡翀 55f42d2771 fix(editor): match IDEA font weights in the light scheme (#35)
Root cause: the editor had no font weight settings, and the WebView rendering of JetBrains Mono looks lighter than IDEA (#32).
Fix (IDEA's Settings › Editor › Font › Typography Settings): configurable Main font weight and Bold font weight (defaults Regular 400 / Bold 700) via --fw-code / --fw-code-bold; colour-scheme bold/regular overrides still win; settings preview updates live. Replaces the Light-only 450/550 hard-coding.
Siblings covered: sticky lines, diff/merge, run console (ANSI bold), Find in Files, completion/parameter info/docs, database editor, debugger value popup, matched braces, terminal with "Use editor font".
Verified: scoped vitest, ide-core tests, in-app on macOS (settings UI, editor weight follows the setting end to end).

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:41:56 +08:00
l0ng-ai 30310810e6 feat(plugins): Theme plugin template, theme docs and IntelliJ theme import (#54)
Per the maintainer's decision for #24, no third-party themes are bundled; instead a theme plugin template and docs let the community port themes (IntelliJ also ships few themes, the rest are plugins).
- templates/theme-plugin: manifest-only plugin with an original example (Harbor Night) covering every UI token and an editor scheme (.icls equivalent).
- docs/plugins/themes.md: format, IntelliJ / VS Code porting tables, generated token reference (pnpm gen:theme-docs); drift test fails when tokens.css and the template/reference disagree.
- pnpm theme:import-intellij: dependency-free converter for IntelliJ .theme.json (+ .icls).
- Plugin host: themes may name an editor-scheme merged over the theme; comments allowed in theme files.
- Fixes: a plugin theme's kind now switches the light/dark base; plugin themes go in their own stylesheet so the Appearance accent and editor scheme still layer on top.
Verified: ide-plugin tests, vitest (44), in-app on macOS: installed the template from disk, applied Harbor Night (dark base + teal accent over Islands Light), back to default restored the light theme.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:39:08 +08:00
l0ng-ai b2fec4ae30 feat(vcs): Show combined changes for multiple selected log commits (#52)
Root cause: the Git Log already supported multi-selection, but the Changes pane only read the last clicked commit and the backend had no "changes across several commits" query.
Fix (IDEA): ide-vcs Repo::commits_changes merges per-file changes across selected commits (before the first touching commit ↔ after the last, renames chained, add-then-delete dropped); the details pane shows "N files in M commits" with the commit list; exactly two commits get "Compare Versions" (version ↔ version dialog, also fixing added/deleted sides).
Siblings covered: Show History for file/folder (same log), Local History multi-select (oldest ↔ newest diff).
Verified: ide-vcs tests incl. temp-repo commits_changes, vcs vitest (143), in-app on macOS: 3 contiguous commits → "2 files in 3 commits"; Cmd-click 2 commits → Compare Versions lists the right files.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:39:01 +08:00
l0ng-ai aa5821bb46 fix(spelling): Rename identifiers on Typo fixes, add inspection controls, cut false positives (#51)
Root causes: Typo "Change to" replaced one occurrence even for declared names (breaking references); inspections had no Disable / Edit settings controls anywhere; en_US Hunspell does not derive agent nouns like "checkpointer" and compound identifier words were flagged; local inspections (Typo, file references, SQL) never reached Problems.
Fix (IDEA): "Typo: Rename to…" renames via LSP rename, falling back to syntax-based local rename, never a silent single replace, and lists identifier-safe words only; intention submenu (→) with Edit inspection profile setting / Disable inspection for every inspection; Problems and Inspection Results get Disable Inspection, Change Severity, Edit Inspection Profile Setting; Typo options Process code / literals / comments; -er/-ers derivation and identifier compounds accepted, common misspellings still flagged.
Siblings covered: all LSP and built-in inspections' intentions, file reference and SQL inspections in Problems, severity counter.
Verified: ide-spell tests (issue examples + ~50 real misspellings), vitest (286), in-app on macOS: only 'mesage' flagged, submenu, Rename to → 'message' updated both occurrences.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:53 +08:00
kevinh eda8cae0a1 feat(project-tree): Exchange files with the OS clipboard on Windows (#41)
Exchange files with the OS clipboard on Windows: CF_HDROP read/write in a new ide-clipboard crate (Win32 FFI behind cfg(windows), stub elsewhere), three thin commands, and project-view copy/paste hooks. Pasting from Explorer always copies; our own hand-off falls back to the internal clipboard so Copy/Move dialogs and refactoring stay intact.
Verified: ide-clipboard round-trip tests, project-tree vitest (93), CI on all three OSes. Not verified on Windows hardware here.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:45 +08:00
l0ng-ai e150101244 feat(debugger): Remote debug run configurations (Remote JVM, Go, Python, Node.js) (#50)
Root cause: run configurations could only launch a process; there was no "attach to a debugger that is already listening" type, and debugServer only connected to 127.0.0.1.
Fix (IDEA / GoLand / PyCharm): debug-only Remote JVM Debug (attach / listen, live JDWP agent args per JDK version), Go Remote (dlv --headless, substitutePath), Python Remote Debug (debugpy --listen, pathMappings) and Attach to Node.js (inspector, localRoot/remoteRoot); debugServer accepts host:port and [v6]:port; JVM listen mode via a cancellable relay so java-debug can attach.
Verified: ide-dap tests (host:port, relay handshake, cancel), run/debugger vitest, in-app on macOS: Python Remote Debug attached to a debugpy server, paused at app.py:3 with variables. Remote JVM/Go/Node not exercised in-app.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:38 +08:00
l0ng-ai 642b80cef0 fix(workbench): Keep a way out of Distraction Free Mode on Windows (#47)
Root cause: on Windows the window is frameless and the main menu lives in the custom title bar; Distraction Free Mode rendered a bare title bar without it, the toggle has no default shortcut (as in IDEA) and the mode persists across restarts, leaving no way out.
Fix (IDEA): the bare title bar keeps the main menu when there is no native menu bar; a lone Alt opens the main menu; entering the mode (or restoring it) shows "To exit Distraction Free Mode, use View | Appearance … or Find Action" with an Exit action, in its own notification group.
Siblings covered: Distraction Free + Full Screen on Windows; other chrome-hiding settings checked (unaffected).
Verified in-app on macOS (hint, Exit action, single Don't Show Again). Windows-only parts (☰ in bare title bar, lone Alt) not verified on Windows.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:26 +08:00
l0ng-ai b4a3e866c6 fix(terminal): render the terminal in the bundled JetBrains Mono (#44)
Root cause: the terminal built its own font-family ("JetBrains Mono", Nerd Fonts, monospace) without the bundled "JetBrains Mono Variable", so on Windows (no JetBrains Mono installed) xterm fell back to the system monospace (SimSun on Chinese Windows); xterm also measured cells before the webfont loaded.
Fix: one resolver (src/platform/fonts.ts monoFontFamily) maps JetBrains Mono to the bundled face; xterm re-measures once fonts load; "Use editor font" also takes the editor's fallback font (IDEA).
Siblings covered: embedded run terminal, editor default family + fallback, Settings › Editor › Font preview.
Verified in-app on macOS (terminal renders JetBrains Mono Variable). Not verified on Windows.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:18 +08:00
l0ng-ai 5670c2e8dd test: stub Range layout methods once in the test setup (#53)
Root cause: jsdom has no layout and CodeMirror measures text ranges in an animation frame, sometimes after a test finished; test files that did not stub Range.getClientRects (e.g. search/codePreview) threw an unhandled TypeError that failed whole CI runs at random (seen on #41).
Fix: stub Range.getClientRects / getBoundingClientRect once in src/test/setup.ts; drop the six per-file copies.
Verified: vitest on editor/lsp, editor/refactor, templates, search (55 files, 458 tests, no unhandled errors).

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:38:11 +08:00
l0ng-ai 843127f4a3 fix(app): Apply the saved IDE zoom before the page loads so the splash doesn't jump
The frontend set the webview zoom once settings loaded, while the splash was
still up, so a non-100% zoom visibly scaled the splash logo at the end.
2026-10-27 10:17:06 +08:00
l0ng-ai 1127263ea8 fix(run): Shorten java command lines too long for the OS (#28) (#45)
Root cause: every java launch inlined the whole classpath into one -cp argument with no "Shorten command line", so long classpaths exceeded CreateProcessW's 32767-char limit (os error 206) before the JVM started.
Fix (IDEA's auto "Shorten command line"): ide-run rewrites a too-long java command line at spawn time — @argfile on JDK 9+ with ASCII paths, otherwise a MANIFEST pathing jar (percent-encoded file: URLs, 72-byte lines); temp files are removed when the process exits. Debug passes the same choice to java-debug's shortenCommandLine.
Siblings covered: Application/Spring Boot, test classpath, Kotlin main, explicit java -cp configs, profiler, PTY launches, Debug.
Verified: unit tests, pathing jar loaded by real JDK 8/11/25, ProcessManager integration test with 3000 entries on JDK 8/17/25. Not verified on Windows.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:13:31 +08:00
l0ng-ai de84a643aa fix(ui): Make read-only content selectable with a Copy context menu (#48)
Root cause: body is user-select: none and each feature had to opt back in; the Markdown preview (and other read-only views) never did, and the app-wide contextmenu guard left no Copy item.
Fix: shared selectableText class (src/ui) for read-only content, and a fallback Copy context menu from browserGuards when right-clicking a selection or a selectable area (component menus still win).
Siblings covered: Quick Documentation / hover / completion docs, hover errors, Quick Definition, debugger value tooltip, inspection messages, message dialogs (no drag on body text), notification titles, About dialog; existing selectable consoles now get Copy too.
Verified in-app on macOS: select in Markdown preview, right-click Copy puts the text on the clipboard; tab context menu unchanged.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 08:56:13 +08:00
l0ng-ai 6dfd82de4c fix(vue): Run Vue Language Server 3 in hybrid mode with a usable TypeScript (#46)
Root cause: the tools dir installed typescript@latest, now TypeScript 7 (native port, no typescript.js/tsserver.js/ts.server), so @vue/language-server 3 crashed on ts.server.protocol and restarted forever; Volar 3 hybrid mode also needs tsserver/request forwarding, which we never answered.
Fix: pin TS-as-library servers to typescript@<7 and keep shared deps on uninstall; pick a usable tsdk (setting / bundled / project, --tsdk); bridge tsserver/request to vtsls with @vue/typescript-plugin (null when vtsls is absent); answer workspace/configuration for section ""; stop restarting after 3 consecutive early crashes (IDEA); crash summary starts at the exception line.
Siblings covered: typescript-language-server and svelte-language-server (same TS 7 break), vtsls config, install hints in language TOMLs.
Verified: scoped cargo tests, real-server tests (vue-language-server 3.3.13 + vtsls), in-app on macOS (server running, no crash loop). Not verified on Windows.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 08:21:42 +08:00
kevinh b79522fd03 fix(java): Point the missing-JDK error at the Language Server JDK setting (#31)
Root cause: missing_runtime_message only suggested JAVA_HOME (process-wide) although a per-app Language Server JDK setting exists since #21.
Siblings covered: server_jdk_too_old_message now names the same Settings page via a shared SERVER_JDK_SETTING const; both asserted in tests.
2026-10-26 23:08:52 +08:00
l0ng-ai d7c27c1184 fix: configure git identity for merge and rebase test 2026-10-26 08:30:07 +08:00
l0ng-ai 6b9a2ec67e fix: sync inspection results with profiles and restore CSS validation 2026-10-25 23:41:15 +08:00
l0ng-ai d3feb056aa chore: v0.1.14
Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:34:56 +08:00
l0ng-ai 1e00ea5ee2 feat: Third pass aligning core experience with IntelliJ IDEA
Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:24:00 +08:00
l0ng-ai 294198a727 chore: v0.1.13 2026-10-25 09:45:20 +08:00
l0ng-ai 82b6980a59 feat: Second pass aligning interactions with IntelliJ IDEA (#23)
* feat: Second pass aligning interactions with IntelliJ IDEA

Result tool windows
- Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO,
  Inspection and Hierarchy, with an "N of M" status hint
- Find: reuse the unpinned tab, Pin Tab, source preview, group by file or
  directory, exclude moves to the next result, Rerun
- Usages: tabs with pinning, exclude and preview
- Problems, TODO, Inspection: no autoscroll by default, severity-first sorting,
  context menu with quick fixes and copy description
- Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling

Find in Files, bookmarks, navigation
- Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select
- Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓),
  Alt mnemonics, search context filter (comments / string literals)
- Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions,
  ⌘-click gutter toggles a bookmark
- Select In popup (⌥F1); Recent Locations double-click and Delete;
  Search Everywhere remembers the query per tab

Code insight and refactoring
- Extract/Introduce continue with in-place rename and name suggestions;
  expression and occurrence choosers with live highlighting
- Safe Delete confirms first; numbered Refactor This
- Quick Documentation history, in-popup links and a Documentation tool window;
  Quick Definition switches between targets and shows the whole declaration
- Goto chooser lists symbol names and can pin to the Find window
- Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary,
  inlay hint ⌘-click and context menu
- Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup,
  language surrounders with mnemonics
- File Structure popup as a filterable tree; Show Usages filter and preview;
  call hierarchy jumps to call sites

Editor
- Paste re-indents each line by default; Move Statement moves whole statements
  in and out of blocks and re-indents
- Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions;
  ⌥⌫ deletes the preceding word
- ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning
- Visual-column block selection, nested Collapse All, recursive folding,
  fold selection toggle, Go to Matching Brace, gutter drag selects lines,
  sticky line and error stripe clicks, Insert toggles overwrite mode

Run, VCS, workbench
- Gutter icons show the last test result; Modify Run Configuration; test tree
  hides passed tests, context menu and assertion diff
- Stack frames resolve by fully qualified class name; context run chooser
- Inline Smart Step Into
- Git Log multi-select filters, "me" and date range; favourite branches,
  Fetch and tags in the branch popup; stash Keep index and Unstash options
- Terminal tab titles follow the shell; Distraction Free persists; gear menu;
  sticky balloons move to the notifications tool window after a while

* fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out

Conflicts from restoring stashed changes show the user's uncommitted
changes on the left like other operations, and the checkout notification
names the new branch instead of the previous one.

* fix: Close the merge editor on Apply; confirm before terminating a running Run tab

Like IDEA, applying a merge result closes its tab, and closing a Run tab
whose process still runs asks before terminating it (with a Settings
toggle and "Do not ask again").

* fix(terminal): Don't start a new session in the background after closing the last tab

The hidden Terminal window stays mounted, so it reopened a session as soon
as the last tab closed. It now opens the Local tab only once the window is
shown again, as IDEA does.

* fix(vcs): Start the merge result from a three-way merge; close the editor on Apply

The result pane now begins with non-conflicting changes merged and each
conflict showing the base text, resolved from the side panes, instead of
raw conflict markers. Apply now reports success, so the tab closes.

fix(search): Match File Structure filter on word starts

* fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles

Adapters that list every remaining call in the function (debugpy) made Smart
Step Into fall back to a chooser that offered calls from later lines. Targets
are now narrowed to the calls on the paused line, so they are picked in place.

Anonymous functions and callbacks no longer get a 'no usages' hint, and
intention titles are no longer cut short when there is room for them.

* fix: Undo / Redo from the Edit menu and Find Action use the editor's history

They ran the webview's native undo, which misses programmatic edits such as
templates, completion and refactorings, so nothing happened in the editor.
2026-10-25 09:44:46 +08:00
l0ng-ai 81e0e8e2d0 feat: Align interactions with IntelliJ IDEA (#22)
* feat(nav): Complete file paths and flag unresolved web file references

Path completion in href/src/url()/@import, Markdown links and path-like strings, folders first. HTML and CSS paths that name no file get a 'Cannot resolve file' warning. File references resolve in the backend like IDEA's: relative to the file, /-rooted against the project and resource roots.

* feat: Align editor, workbench, run/debug and VCS interactions with IntelliJ IDEA

Keymap
- Let AltGr characters through instead of firing Ctrl+Alt shortcuts (Windows/Linux)
- Double Shift: strict press/release timing, ignored while typing or after Shift+click
- Chords wait 2s and swallow an unmatched second stroke
- Drop CodeMirror defaults that clash with IDEA keys; ⌘Enter is Split Line, add
  Scroll to Center and Code Block Start/End
- Add missing IDEA defaults (⌃J, ⌘Y, ⇧⌘C, ⌥⌘Y, ⇧⌘M, F9 Resume, ⌘R Rerun, …)

Editor
- Tab inserts at the caret; smart Backspace to the proper indent; Enter continues
  a `//` comment; per-language line comment placement
- Back/Forward records navigations only; ⌘F searches the selected text
- In-place rename; ⌃Space inserts a sole candidate; commit characters only for a
  focused lookup; usage highlighting survives edits; ⌥Enter offers fixes from the
  caret line; ⌘-hover shows declaration info only for navigable targets

Workbench
- Tool windows: primary and split area per side, six Move To anchors, layout migration
- ⌘W closes tool window content; Shift+Esc from the editor; closing a tab
  activates the left neighbour; preview tab and tab limit follow IDEA defaults
- Switcher lists open editors and tool windows; Recent Files Delete removes items
- Search Everywhere tab/scope behaviour; popups close when the window deactivates
- Project view: Copy/Move dialogs on paste, skippable usage search on delete,
  new-file input cancels on blur; Settings search Esc clears the filter
- Status bar breadcrumbs open a children popup; notification timeouts

Run / Debug / Build / Terminal
- Run asks to stop a running single-instance config; Rerun restarts silently;
  "Allow multiple instances" option
- Run chooser: Enter runs, Shift debugs, F4/Delete/digits
- Type into the console for stdin; Stop covers debug sessions
- Log (Shift+click) and temporary (Alt+click) breakpoints; independent suspend/log
  options; Evaluate prefill and history; changed-value highlighting; expandable hints
- Build window opens on failure and jumps to the first error
- Terminal confirms closing a busy tab and lets IDE shortcuts through

VCS
- Correct ours/theirs labels during rebase, cherry-pick and revert
- Smart/Force Checkout; remote branch checkout updates the local branch
- Partial commit by changed block; inline before-commit check results
- F7 continues into the next file; Rollback Lines; Commit and Push; Amend message swap
- Branch delete with Restore; force push confirmation; editable push target;
  update summary with the incoming commits

* chore: Regenerate bindings
2026-10-25 09:34:02 +08:00
l0ng-ai 87b463990c feat(nav): Follow links, file paths and CSS references with Go to Declaration
Go to Declaration and Cmd-click said "Cannot find declaration" on HTML href/src, CSS url() and @import, Markdown links and CSS selectors: language servers expose paths as document links, not definitions, and none resolves class/id to selectors.

- ide-lsp: textDocument/documentLink (lsp_document_links), resolving links that lack a target.
- Editor: when the server has no definition, follow its document links, then URLs and quoted / Markdown / url() paths in any language. Server file links are checked to exist; root-relative paths also resolve against resource roots. Cmd-hover underlines the whole link.
- ide-language: tree-sitter web references (class / id attributes, href="#id", var(--x)) and .class / #id / --var definitions from stylesheets and <style> elements, indexed as symbols. web_goto looks them up by exact name (ProjectIndex::symbols_named), using the open document's unsaved text.
- Detect public/ of package.json modules as a resource root (Vite, Next, CRA serve it at /).
2026-10-24 22:58:23 +08:00
l0ng-ai 046fd37064 fix(ui): Match PyCharm project view, matched tags and text weight
- Project widget initials follow IDEA's AvatarUtils.initials ("idea" → "I").
- "Show directories with changed descendants" is off by default, as in IDEA.
- FileEntry gains `excluded`: only excluded folders get the excluded icon and
  dimming; git-ignored ones keep the plain icon and the ignored colour.
- `.github` folders get IDEA's GitHub folder icon.
- Matched tag names are never bold and use their own background
  (MATCHED_TAG_NAME); matched braces are bold in dark schemes only.
- Revert Greyscale antialiasing to `antialiased` for UI and editor; `auto`
  rendered text heavier than PyCharm.

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 22:56:48 +08:00
l0ng-ai cacddee19d feat(web): Open HTML in browsers with a built-in server; add Emmet
- ide-web: built-in static server on 127.0.0.1 that serves project files,
  plus detection and launching of installed browsers.
- Open In › Browser in the editor, project view and tab menus; browser bar
  and built-in preview beside HTML editors; ⌃⇧R opens the default browser.
- Emmet: Tab expansion in markup editors and Update Tag with Emmet.

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 22:16:28 +08:00
l0ng-ai c8cea62eab fix(editor): Match PyCharm syntax colours and text weight
Greyscale antialiasing mapped to `-webkit-font-smoothing: antialiased`,
which drops macOS stem darkening, so editor and UI text rendered thinner
and syntax colours looked lighter than PyCharm's. Use `auto` instead.

Align lezer tag mappings with PyCharm's Light scheme:
- HTML/XML: `<!doctype>` is plain text (documentMeta is a sub-tag of meta).
- CSS/SCSS/Sass: property names use the attribute colour; values and units
  the attribute-value colour; class, id, pseudo, colour and url() the tag colour.
- YAML: keys are keywords.
- Markdown: quotes are strings with keyword markers, link destinations
  italic function colour, titles strings, horizontal rules keywords.

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 22:16:16 +08:00
l0ng-ai 4a2d5c9599 chore: v0.1.12
Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 18:26:59 +08:00
l0ng-ai 059f25f13e feat(java): Separate the jdtls JDK and add Settings for New Projects (#21)
- Languages & Frameworks › Java › Language Server › JDK (languages.java.jdtlsJdk) runs jdtls on its own JDK, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK.
- Toolchain pages get the Global / This project switch (default This project); Global shows and edits the global layer.
- File › New Projects Setup › Settings for New Projects… edits only the global layer, which new projects inherit.
- Configuration errors (launch_error) show as "Not started" instead of "Crashed".

Fixes #18
Fixes #19

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 18:26:31 +08:00
@鹿таl0ng-ai a765959d48 fix(process): Spawn child processes without a console window (#17)
- The IDE is a GUI-subsystem binary, so Windows allocated a fresh console
  window for every console-subsystem child it started: one black window per
  language server, per settings-driven restart, per debug adapter and per
  toolchain probe, titled with the child's own path. Closing it killed the
  child, which is how editing the language-server settings broke highlighting.
- Peer::spawn, the spawn point for every language server, wired stdio but never
  passed CREATE_NO_WINDOW. The rustup-proxy probe, the DAP adapter, debugpy
  probe, runInTerminal and cargo spawns, the SDK probes, the run-configuration
  probes, the coverage exporter and tool diagnostics/downloads (curl, tar) had
  the same gap, while git, cargo builds, gh, docker and run configurations
  passed the flag inline — five copies of the magic constant, six subsystems
  without it.
- Add ide_core::process::{CREATE_NO_WINDOW, hide_console_window} as the single
  definition, route every spawn through it (tokio commands via as_std_mut()) and
  fold the inline copies into it.
- Verified locally on Windows with a console-detached harness: a plain spawn
  reports a visible console window (GetConsoleWindow + IsWindowVisible), the
  patched Peer::spawn reports a console without a window. cargo check, cargo
  clippy --all-targets and the affected test suites (249 tests) are green.

Co-authored-by: l0ng-ai <[email protected]>
2026-10-24 16:57:34 +08:00
l0ng-ai efbb48c3cb chore: v0.1.11 2026-10-24 16:32:25 +08:00
l0ng-ai bb9486c758 fix(app): Stop console tools from opening cmd windows on Windows (#20)
Release builds use the GUI subsystem, so a console child spawned without
CREATE_NO_WINDOW gets its own console window. git, gh, docker, builds and
run configurations already set the flag; language servers, SDK and tool
probes, debug adapters, tool downloads, coverage export and perf did not.
Add ide_core::process::no_window and use it at every remaining spawn.

Fixes #16
2026-10-24 16:31:50 +08:00
l0ng-ai 5c0c3a009c docs: Keep the demo video as the README's only hero 2026-10-24 14:50:08 +08:00
l0ng-ai ef667f6e21 chore: v0.1.10 2026-10-24 14:48:53 +08:00
l0ng-ai 81b43a52b4 docs: Trim the README to a link row, screenshots and the benchmark 2026-10-24 14:48:31 +08:00
l0ng-ai 0bb730039d feat(search): Bring Search Everywhere closer to IDEA's popup
- All tab interleaves classes, files, symbols, actions and text by match
  degree, keeping each source's own order; Enter waits for every source
  except a text search the top row already beats.
- Preview pane under the list (remembered toggle), reusing CodePreview.
- "/" lists scope commands; "/files q" switches scope and searches q.
- Per-tab search history: magnifier dropdown and ⌥↑ / ⌥↓.
- Text hits: syntax colouring, IDEA's boxed-T icon, dimmed repeated file
  names, collapsed indentation.
- Flat scope chooser, IDEA header icons, wider popup with a stable list
  height; the list shrinks so the footer stays visible.
2026-10-24 14:47:45 +08:00
l0ng-ai 709e1eb855 docs: Drop the feature list from the README 2026-10-24 14:46:31 +08:00
l0ng-ai 4473324822 docs: Recapture the README editor screenshot with a focused window 2026-10-24 14:45:35 +08:00
l0ng-ai 5d5b3e4791 docs: Recapture README screenshots with the native window frame 2026-10-24 14:36:44 +08:00
l0ng-ai c5b4b71e30 docs: Lay out README screenshots as a hero plus an even pair 2026-10-24 14:34:29 +08:00
l0ng-ai 0e299ad936 docs: Add download links, screenshots and a feature list to the README 2026-10-24 14:31:17 +08:00
Richardтаl0ng-ai 5b8e0b9659 feat(i18n): 新增简体中文界面翻译 (#15)
* feat(i18n): 新增简体中文界面翻译

* test(i18n): 补充中文界面截图与端到端验证

* fix(i18n): Translate notifications and usages through catalog templates

Move the hard-coded Chinese notification patterns into source-text
templates (`{name} failed`) applied at render time, so text follows
locale switches and stays English under en. Use useTx in Search
Everywhere and add its missing zh strings.

* chore: Drop i18n screenshots

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-10-24 14:14:43 +08:00
l0ng-ai 3d8a6f07b4 feat(app): Enable signed auto-updates from GitHub releases
Configure the updater public key and the releases/latest latest.json
endpoint, and build updater artifacts so tauri-action uploads signatures
and latest.json. Check for updates once per app start (release builds
only) and offer Install and Restart when one is available.
2026-10-24 14:13:12 +08:00
l0ng-ai 99b4f8bdf6 fix(terminal): Render with xterm's DOM renderer
The WebGL renderer rasterizes each glyph against its cell background, so
text over the selection came out at a visibly lighter weight than the
rest and looked like a different font. The DOM renderer draws text like
the editor does, selected or not.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 13:42:07 +08:00
l0ng-ai 86f1f666d7 chore: v0.1.9
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 13:15:41 +08:00
l0ng-ai be49490153 feat(terminal): Align the terminal with IDEA's tabs, menu and settings
- ⌘T (Ctrl+Shift+T on Windows) opens a terminal tab while the terminal
  has focus; elsewhere the key keeps its IDE action.
- The tab bar's ▾ menu lists installed shells under "New Predefined
  Session" (login shell, shell integration) and links to Settings.
- Settings › Tools › Terminal: pick the shell path from the installed
  shells or browse for one, "Move focus to the Editor with Escape",
  fallback font, column width and a link to the Terminal keymap.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 13:02:52 +08:00
l0ng-ai 1f5fcb477d fix(terminal): Default to the account's login shell
$SHELL is inherited from whatever launched the app, so starting it from a
terminal running bash opened bash even when the login shell is zsh. Read
the passwd entry first and fall back to $SHELL.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 12:36:40 +08:00
l0ng-ai 1bb454bd2f fix(terminal): Match the xterm scrollbar to the IDE's
Style xterm's own scrollbar slider with the scrollbar tokens, narrow it to
10px and blend the overview ruler border into the background.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 12:36:40 +08:00
l0ng-ai f38f63e397 test(e2e): Drive the Language Servers page through its list and details
The page is no longer a table: pick the tool in the list, then act in
its details pane. Fixes the Frontend CI failures since v0.1.7.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 12:11:21 +08:00
l0ng-ai 08d87c3903 chore: v0.1.8 2026-10-24 11:47:57 +08:00
l0ng-ai 1652866769 fix(workbench): Drag tool window stripe buttons with pointer events
With the window's native file drop enabled, the webview never receives
dragover / drop for HTML5 drags that start inside it, so stripe buttons
could not be moved. Track the drag with pointer events instead: a ghost
follows the pointer, the drop line shows the target, Escape cancels, and
an empty right stripe appears as a drop target while dragging.
2026-10-24 11:47:02 +08:00
l0ng-ai be5a6f62ad feat(editor): JetBrains-style line-number gutter context menu
Right-click on the gutter now shows Annotate with Git Blame, Add/Remove
Bookmark, Soft-Wrap, Configure Soft Wraps, an Appearance submenu and
Configure Gutter Icons, contributed through a new "editorGutter" menu
group. A breakpoint line still opens the breakpoint popover.
2026-10-24 11:38:08 +08:00
l0ng-ai f5e8d77dcf chore: v0.1.7
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:34:42 +08:00
l0ng-ai c3ea376899 feat(tools): Install 15 more language servers and debug adapters
Release downloads: buf, clojure-lsp, texlab, neocmakelsp, LemMinX, Perl
Navigator, kotlin-language-server, kotlin-debug-adapter and netcoredbg;
Open VSX: bash-debug and php-debug (run through node). Two new methods:
`gem` (ruby-lsp, solargraph), installed into the tool's folder and
launched with GEM_HOME, and `dotnet tool` (csharp-ls, fsautocomplete),
launched with DOTNET_ROOT so a Homebrew / user-local .NET is found.
Tools declare the runtime they need (Java, Node.js, Perl).

Each was installed from its real source and started on macOS arm64.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:26:31 +08:00
l0ng-ai fde075457b feat(editor): Tree-sitter highlighting for Zig, Nix, Make, GraphQL and HCL
Every grammar-only language now gets colours: Zig, Nix and Make from the
queries their crates ship, GraphQL and HCL (Terraform) from our own, as
those crates ship none.

Grammar queries follow two conventions, so the precedence is set per
grammar: nvim-style (the later pattern wins) or tree-sitter CLI style
(the first one wins, Nix). nvim's `#lua-match?` is evaluated; patterns
with predicates we cannot evaluate no longer match unconditionally,
which coloured every Zig identifier as a constant.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:26:31 +08:00
l0ng-ai 135723bca6 feat(tools): Install ElixirLS and its debugger, checking for Elixir first
ElixirLS ships one platform-independent zip of launcher scripts that
build the server with the project's Elixir / OTP on first start. Both
the language server and the debug adapter are installed from it, each
linked to its own launcher (language_server / debug_adapter).

Tools can now declare runtimes they need on PATH; Install stays
disabled with the reason ("Elixir is required") until they are found.

The Elixir debug adapter is renamed elixir-ls-debugger so it no longer
shares the server's tool name.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:26:31 +08:00
l0ng-ai 03a660354c feat(tools): Custom language server paths and a list/details Language Servers page
Servers and debug adapters could only be found on PATH or in the IDE's
tools folder; the only way to pick another executable (a version that
matches the project's Elixir/OTP, a local build) was an undocumented
languages/*.toml override. `tools.<command>.path` now names the
executable to launch: language servers (configure_server, restarted on
change), debug sessions and the tool list honour it, the latter
reporting a Custom status with its version or why the path is unusable.

The Language Servers page is laid out like Plugins: a compact list
grouped by availability on the left, the selected tool's command, path,
location, version, install actions and hints on the right.

A debugger named like its language server (ElixirLS's elixir-ls /
elixir-ls-debugger) was folded into the server's row and never listed;
it now gets a row of its own.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:26:31 +08:00
l0ng-ai 1572fc36ea feat(editor): Highlight languages without a CodeMirror mode from tree-sitter
Elixir files opened as plain text: highlighting only came from
@codemirror/language-data, which has no Elixir mode. Grammars now carry
their own highlights.scm; `doc_highlights` folds its captures into a small
kind vocabulary (inner nodes and later patterns win, interpolation clears
the string colour), memoised per document version on the incremental
tree. The editor maps kinds onto Lezer tags, so colours follow the active
scheme, and requests them only when no CodeMirror mode exists.

Enabled for Elixir; other grammar-only languages need their query wired.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-24 11:26:31 +08:00
l0ng-ai 4f74f22624 fix(ui): Size Select popups to the widest option and truncate the PR repo name
Virtualised list rows are absolutely positioned, so the popup only took the
trigger's width and cut short labels like "Merged". The Pull Requests header
now keeps the repository on one line with an ellipsis and a tooltip.
2026-10-24 10:02:13 +08:00
l0ng-ai 1309ce10e4 chore: v0.1.6 2026-10-24 09:21:16 +08:00
l0ng-ai 49be259232 fix(ui): Show the full SDK name in selects and shorten Windows home paths
A long detail (an installation path) was not cut, so it pushed the option label out
(e.g. "Eclipse Adopti…", the Maven name hidden) and scrolled the popup sideways. The
label now keeps its width and the detail is cut with an ellipsis (full path in the
tooltip, detail text translated). Paths under C:/Users/<name> are shown as ~/…
2026-10-24 09:11:58 +08:00
l0ng-ai 6a8016b888 fix(vcs): Keep submodule entries out of Rollback and refresh the log of another repository
- Rollback no longer offers a submodule's own entry: it cannot be reverted from the
  enclosing repository (it did nothing while reporting "Rolled back 1 file"). Rolling
  back only that entry explains how to revert it instead.
- A selected change is rolled back alone; only folders take the changes below them, so
  selecting a submodule's entry no longer pulls in the uncommitted files inside it.
- The Git log loads the branches of the newly selected repository, so it reloads when
  that repository's refs move (a new branch showed up only after a manual refresh).
2026-10-24 09:11:58 +08:00
l0ng-ai b8f422d451 fix(vcs): Route a nested repository's own entry to the enclosing one and harden multi-root
Review fixes for several repositories in one project:

- A path equal to a nested repository's root (a submodule's entry, an untracked
  clone) belongs to the enclosing repository, in the backend and the Commit view.
  It was routed to the nested repository as ".", so Rollback ran `git restore` and
  `git clean` over the whole submodule and Commit staged everything in it.
- Repository-scoped commands take the repository root exactly and fail when it is
  gone instead of running in an enclosing repository; path-based ones resolve the
  file's repository (Project::with_repo_of).
- The rescan on a repository appearing or going watches `.git/HEAD`: the watcher
  never reports a `.git` path itself, so the old check never fired.
- Status skips a repository that cannot be read; refresh falls back to the primary
  repository when the selected one is gone instead of failing every time.
- Commit and Compare-with-Local diffs resolve repository-relative paths against the
  selected repository's root, not the project root.
- Update Project and Fetch go on after a failing repository and report the failures
  together; Update skips repositories with a detached HEAD or no upstream.
- The Push dialog leaves out a repository that cannot be read.
- Shelve and patches fail clearly for changes of a nested repository instead of
  leaving them out silently (they still support the main repository only).
- Fix the repository discovery test on Windows (path separators); Chinese text for
  the new strings.
2026-10-23 23:15:09 +08:00
l0ng-ai c868ec8b68 fix(java): Apply the Maven settings to jdtls and Lombok, find more Maven installs
Review fixes for the Project JDK / Maven settings:

- jdtls gets the user settings.xml as java.configuration.maven.userSettings, so the
  editor resolves dependencies through the same mirrors and servers as sync and builds.
- The configured local repository (else <localRepository> of the chosen settings.xml)
  is where the static POM model looks for parent POMs / BOMs and where the Lombok
  agent is looked for.
- Maven discovery finds mvn.cmd on PATH on Windows and the homes Maven Wrapper 3.3+
  downloads (dists/<name>/<hash> without a nested folder).
- A Project JDK pointing at the JDK's bin directory is accepted.
- Detected run configurations reload when language settings change, so Maven and
  Gradle runs use the new JDK, Maven home and options.
- Chinese text for the new settings.
2026-10-23 23:15:09 +08:00
l0ng-ai 059a5193ff feat(vcs): Support several git repositories in one project (multi-root)
Only the repository containing the project root was known, so nested clones and
submodules (or a folder of side-by-side service repositories) showed no changes and
could not be committed or pushed. The project now has IDEA's VCS roots: the enclosing
repository plus those found below the project (scanned in the background, rescanned when
a .git appears or goes). Status covers all of them; path-based commands go to the file's
repository and repository-scoped ones take the selected repository.

The Commit view groups changes by repository and commits each one with included changes;
the branch widget names the selected repository and switches between them; Update Project
and Fetch run in every repository; Push lists every repository with outgoing commits; a
repository that stops in a merge or rebase is selected so Continue / Abort act on it.

Closes #12
2026-10-23 23:15:09 +08:00
l0ng-ai 0f7008391c feat(java): Add Project JDK and Maven home / settings.xml / local repository settings
The JDK was always picked automatically (JAVA_HOME, PATH, the usual install dirs, by the
module's Java version) and Maven was the project's mvnw or mvn on PATH, with no way to
override either. languages.java.jdk (Languages & Frameworks › Java › Project JDK) now sets
the default jdtls runtime, the JDK of Run/Debug and JAVA_HOME for Maven and Gradle;
languages.java.maven (Build Tools › Maven) sets the Maven home, -s and
-Dmaven.repo.local for sync, builds, tool-window goals and detected run configurations.
JDKs and Maven installations are detected for the pickers.

Closes #13
2026-10-23 23:15:09 +08:00
l0ng-ai c59f93988b feat(vcs): PyCharm-style Commit view and one shared message dialog
- workbench/messages: one promise-based ask/confirm/confirmChecked/showError
  host replaces the per-feature confirm dialogs (app, services, vcs,
  project tree, keymap, run, database, find, LSP choice popup).
- Commit tool window: Commit | Shelf | Stash tabs as the title, IDEA's
  toolbar (Update Project, View Options, Select Opened File), Unversioned
  collapsed, Commit / Commit and Push on one row, empty-message error.
- Commit Options popup: author, sign-off, before-commit checks, Git hooks;
  vcs_commit takes CommitOptions (--author, --no-verify).
- Remove from Recent Projects asks first; refused history rewrites show an
  error dialog.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 22:59:06 +08:00
l0ng-ai 76d33f0306 feat(ui): IDEA-style message dialogs, project widget popup and Chinese UI text
Message dialogs: `Dialog` gets an `icon` (question / info / warning / error)
that renders IDEA's alert layout: no title bar, a 32px icon, bold title,
text, checkbox under it. Every confirmation helper (prompt, VCS, services,
project tree, keymap, LSP choices, run, database, Replace All) uses it. Open
Project matches IDEA: "This Window" is the default and has focus. The Code
Analysis and TODO commit checks default to Review.

Project widget: the title bar project button opens a popup like IDEA's
ProjectWidget, with Open… / Clone Repository…, the projects open in other
windows (new `window_open_projects` command) and recent projects, filtered by
typing.

Chinese UI: UI text stays written in English and is translated through a
source-text catalog (`locales/text/zh.json`, about 2300 strings). The UI
primitives (buttons, checkboxes, selects, tabs, dialogs, menus, tooltips,
toasts, settings labels and hints) translate the strings they are given.
Action titles and categories are localised in the registry, and static JSX
text uses `<T>`, so text follows a locale switch without a reload. The
Settings window now syncs the locale too. Settings search matches both
languages.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 22:18:50 +08:00
l0ng-ai cd55bc8f7f chore: v0.1.5 2026-10-23 21:38:02 +08:00
l0ng-ai deec86fab9 refactor(perf): Remove the Memory Usage settings page
It was a live metrics view, not a setting, and duplicated the memory
indicator's popup, its status bar toggle and Clear Caches.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 21:35:02 +08:00
l0ng-ai 29e89f2525 fix(app): Give the Settings window a plain loader and make the splash bar progress
The Settings window loads the same page as project windows, so it showed the
launch splash with the logo. It now shows only the bar, after 400 ms.

The bar's sliding animation was switched off under reduced motion, leaving a
static 40% fill. It is now a fake progress that eases towards ~90% and fills
to 100% before the splash fades out.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 21:31:57 +08:00
l0ng-ai b1bb4a69cd fix(app): Keep the dark theme's background under a fast window resize
splash.css painted html light and only switched body to dark, so the page
canvas showed the light colour wherever a live resize outran relayout.
Leave html unpainted so the canvas takes body's themed background.

Also set the webview's background (not just the native window's) on a
theme change; it otherwise kept the colour the window was created with.
2026-10-23 21:29:45 +08:00
l0ng-ai ee99b3641f fix(ui): Hide the focus outline around dropdown popups
The global :focus-visible rule loads after the module CSS and has the same
specificity, so it overrode `outline: none` on the focused listbox wrapper
and drew a square blue frame inside the Select popup.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 21:23:06 +08:00
l0ng-ai 19aee5f262 fix(app): Never show the webview's native context menu (#11)
Right-clicks the app doesn't handle itself, such as on code-lens or inlay
widgets inside the editor or in text fields, fell through to WebKit's own
menu, whose Reload item reloads the whole app. Block it everywhere; the
app's own menus already call preventDefault before this guard runs.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 20:02:58 +08:00
l0ng-ai acf6931017 fix(java): Run jdtls with a Lombok agent and show build errors at launch (#9)
* fix(java): Run jdtls with a Lombok agent and show build errors at launch

jdtls compiles with ECJ, which only generates Lombok members when the
server JVM runs -javaagent:lombok.jar. Nothing put it there, so @Data /
@Setter classes lost their accessors and Run launched stale classes
with "Unresolved compilation problem" stubs and no explanation.

- Projects whose build files mention Lombok get the newest jar: the
  IDE-managed one (new "Lombok (jdtls agent)" tool, latest release from
  Maven Central), else ~/.m2 (incl. settings.xml localRepository) or the
  Gradle cache. Each agent gets its own jdtls workspace so everything is
  rebuilt with it.
- Settings › Languages & Frameworks › Java › JVM options
  (java.jdt.ls.vmargs); a Lombok agent there wins.
- Run (and Debug, as a notification) list the first compile errors when
  the build has errors, plus a hint when Lombok is missing or older than
  1.18.34.

Fixes #3

* fix(java): Base the Lombok hint on the agent jdtls actually runs

A Lombok agent in Settings › Java › JVM options wins over the managed
and cached jars, but the launch hint still looked only at those: with
no cached jar it said Lombok was missing, and with an old cached one it
blamed that version although jdtls ran the user's. The hint and the
startup warning now use the user's agent (version from its file name).
2026-10-23 18:46:13 +08:00
l0ng-ai 555ea8901f chore: v0.1.4
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 18:41:02 +08:00
l0ng-ai 769c1248d6 fix(app): Load the splash CSS from a file so release editors keep their styles (#10)
Release builds hash inline <style> blocks from index.html into the CSP.
With a hash present, style-src 'unsafe-inline' is ignored, so CodeMirror's
runtime-injected styles were blocked and the editor rendered blank (bare
line numbers, no text). Dev builds don't rewrite the CSP, so it only
showed in v0.1.3 release bundles.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 18:40:49 +08:00
l0ng-ai 61ab1851fa chore: Remove stale .ideal and .vscode folders 2026-10-23 17:48:52 +08:00
l0ng-ai c6a1a36676 chore: v0.1.3
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 17:30:21 +08:00
l0ng-ai 6165cb0174 perf: Add a black-box comparison with other IDEs (#6)
perf/compare/bench.py launches each IDE in apps.toml on the same project
and measures it from the outside, the same way for each: time to the
project window, to CPU settling and to log markers; memory and CPU of the
whole process tree; idle CPU, exit time, cache and install size. Runs on
Windows, macOS and Linux with psutil; --wipe-caches measures a cold index.

README: a Benchmark section with the first results against IntelliJ IDEA.
2026-10-23 16:54:39 +08:00
l0ng-ai 79f6caeaa8 test: Count ignored names in the excluded-folders glob test (#8)
a66f01e adds files.ignoredNames to exclude_globs(), so the expected
length is file_excludes() + 1, not files.exclude + 1. The test failed on
every platform since.
2026-10-23 16:41:18 +08:00
l0ng-ai dbfe66a119 chore: Add generated IDE-state .gitignore files 2026-10-23 15:40:41 +08:00
l0ng-ai a66f01ec87 feat(files): Add Ignored Files and Folders setting, honoured everywhere
Name patterns the IDE treats as nonexistent (.DS_Store, __pycache__, *.pyc, VCS dirs, ...) were hard-coded in five places, and Git changes ignored none of them, so .DS_Store showed up as unversioned. files.ignoredNames (IDEA's default list) now drives the project view, navigation bar, index, watcher, Find in Files, inspections and the Git status.
2026-10-23 15:40:41 +08:00
l0ng-ai bf06ca7832 feat(app): Show a themed splash until the launch project opens
Project windows are created in setup with the saved theme's frame colour, and the splash covers the first render while the launch project opens, so a window no longer flashes white or the Welcome screen. Single-instance is now release-only so a dev build can run beside an installed release.
2026-10-23 15:40:41 +08:00
l0ng-ai 63a03d1c08 fix: Status refresh loop, double project open at startup, jdtls Java requirement (#5)
* fix: Stop the status refresh loop and the double project open at startup

Every status rewrote .git/index, even with nothing to persist: gix's
`write_changes` writes an empty change set too. The watcher reports
.git/index writes as VCS changes, so each status triggered the next one,
together with an ahead/behind walk. On a repository with a long history
(freqtrade, 33k commits behind upstream) that kept a core busy for as long
as the project was open. Write back only when `has_changes()`.

A project passed on the command line was opened twice: the launch and the
"reopen last project" restore ran concurrently, and the restore checked
for an open project before the launch had finished. The second open found
the index cache locked and indexed in memory only; with a different last
project, the window ended up on whichever finished last. The restore now
waits for the startup launch.

* fix(lsp): Run jdtls on the Java version its bundles need

jdtls's launcher checks for Java 21, and so did JDTLS_MIN_JAVA, but the
Eclipse platform a release ships moves on: 1.63 bundles
org.eclipse.core.filesystem with JavaSE-25, so on a JDK 21 the server died
resolving its bundles ("jdtls is not ready (Crashed)") and the error told
users to install the 21 they already had.

Read the requirement from the installed jdtls instead: the highest osgi.ee
capability / Bundle-RequiredExecutionEnvironment among its plugins'
manifests (cached per install), never below 21. The runtime choice, the
rescan while no JDK fits and the "needs JDK N" message use it.

Fixes #4
2026-10-23 15:20:36 +08:00
l0ng-ai 4be1b152ef fix(ui): Drop the inner focus outline on text and search fields
The global :focus-visible outline outranked the module's `outline: none`
by load order, drawing a second ring inside the framed field's own focus
ring (visible on the Settings search box).

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
2026-10-23 14:52:13 +08:00
1447 змінених файлів з 207653 додано та 15179 видалено

No files matched your search

+2
Переглянути файл
@@ -0,0 +1,2 @@
# Repository-wide oxfmt formatting (#135)
55836e1b4961c9d87df8503ec374e624c81d6bf6
+38 -3
Переглянути файл
@@ -7,7 +7,8 @@ on:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# A newer push supersedes the run in progress, on main too.
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -29,7 +30,10 @@ jobs:
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
# Only main saves the cache (saving takes minutes on Windows); PRs restore main's.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
- name: Linux deps
if: runner.os == 'Linux'
@@ -45,9 +49,25 @@ jobs:
# Test fixtures write LF files; keep Git from converting them on checkout.
- run: git config --global core.autocrlf false
- run: cargo nextest run --workspace --no-fail-fast
# nextest ran export_bindings, which rewrites src/bindings.ts.
- name: Bindings are up to date
if: runner.os == 'Linux'
run: pnpm gen:bindings && git diff --exit-code src/bindings.ts
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release.yml):
# keep that build working.
server:
name: Remote server (linux musl)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-musl }
- uses: Swatinem/rust-cache@v2
with: { key: server-musl }
- run: sudo apt-get update && sudo apt-get install -y musl-tools
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
frontend:
name: Frontend
@@ -61,10 +81,25 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm test
- run: pnpm build
- run: pnpm exec playwright install --with-deps chromium
e2e:
name: E2E
runs-on: ubuntu-24.04
# 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
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm e2e
env:
HOME: /root
- uses: actions/upload-artifact@v4
if: failure()
with:
+42 -1
Переглянути файл
@@ -131,8 +131,49 @@ jobs:
gh release upload "$TAG" "$dmg" --clobber
done
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
publish:
needs: [create-release, build]
needs: [create-release, build, server]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
+4 -2
Переглянути файл
@@ -13,8 +13,7 @@ dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.vscode
.idea
.DS_Store
*.suo
@@ -34,3 +33,6 @@ playwright-report/
# Local AI agent files
CLAUDE.md
.claude/
# Local reference screenshots
pycharm-screenshots/
+3
Переглянути файл
@@ -0,0 +1,3 @@
# Per-user IDE state
/shelf/
/changelists.json
+26
Переглянути файл
@@ -0,0 +1,26 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"sortPackageJson": false,
"ignorePatterns": [
"dist",
"target",
"src/bindings.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
"crates/**/tests/fixtures"
],
"overrides": [
{
"files": ["**/*.md"],
"options": {
"embeddedLanguageFormatting": "off"
}
}
]
}
+150
Переглянути файл
@@ -0,0 +1,150 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react"],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true
},
"ignorePatterns": [
"dist",
"target",
"src-tauri",
"crates",
"src/bindings.ts",
"fixtures",
"plugins",
"test-results",
"playwright-report",
".claude"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"react/static-components": "error",
"react/use-memo": "error",
"react/preserve-manual-memoization": "error",
"react/incompatible-library": "warn",
"react/immutability": "error",
"react/globals": "error",
"react/refs": "error",
"react/set-state-in-effect": "error",
"react/error-boundaries": "error",
"react/purity": "error",
"react/set-state-in-render": "error",
"react/unsupported-syntax": "warn",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
}
]
}
-4
Переглянути файл
@@ -1,4 +0,0 @@
dist
target
src/bindings.ts
pnpm-lock.yaml
-1
Переглянути файл
@@ -1 +0,0 @@
{ "printWidth": 120, "semi": true, "singleQuote": false, "trailingComma": "all" }
-3
Переглянути файл
@@ -1,3 +0,0 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
Створено
+239 -28
Переглянути файл
@@ -3461,6 +3461,18 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "gloo-timers"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -3960,9 +3972,27 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-vcs",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
name = "ide-analysis"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -3976,7 +4006,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -3989,21 +4019,26 @@ dependencies = [
"tracing",
]
[[package]]
name = "ide-clipboard"
version = "0.1.22"
[[package]]
name = "ide-core"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"dunce",
"serde",
"serde_json",
"specta",
"specta-typescript",
"thiserror 2.0.21",
"tokio-util",
]
[[package]]
name = "ide-coverage"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-run",
@@ -4017,7 +4052,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4034,14 +4069,13 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"dirs 6.0.0",
"futures",
"ide-core",
"ide-ssh",
"parking_lot",
"russh",
"russh-config",
"serde",
"serde_json",
"specta",
@@ -4054,20 +4088,21 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"which",
]
[[package]]
name = "ide-github"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4079,9 +4114,50 @@ dependencies = [
"which",
]
[[package]]
name = "ide-host"
version = "0.1.22"
dependencies = [
"dirs 6.0.0",
"dunce",
"ide-agent",
"ide-analysis",
"ide-build",
"ide-core",
"ide-coverage",
"ide-dap",
"ide-db",
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-remote",
"ide-run",
"ide-sdk",
"ide-search",
"ide-spell",
"ide-ssh",
"ide-syntax",
"ide-terminal",
"ide-text",
"ide-tools",
"ide-vcs",
"ide-vfs",
"ide-workspace",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "ide-http"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"parking_lot",
@@ -4099,7 +4175,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"blake3",
"criterion",
@@ -4121,8 +4197,9 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"encoding_rs",
"futures",
"ide-core",
"parking_lot",
@@ -4136,7 +4213,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"blake3",
"criterion",
@@ -4145,6 +4222,7 @@ dependencies = [
"parking_lot",
"regex-lite",
"serde",
"serde_json",
"specta",
"toml 0.9.12+spec-1.1.0",
"tracing",
@@ -4157,6 +4235,7 @@ dependencies = [
"tree-sitter-css",
"tree-sitter-dart",
"tree-sitter-elixir",
"tree-sitter-gdscript",
"tree-sitter-go",
"tree-sitter-graphql",
"tree-sitter-haskell",
@@ -4188,7 +4267,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"futures",
"globset",
@@ -4211,7 +4290,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-language",
@@ -4229,7 +4308,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-run",
@@ -4240,14 +4319,32 @@ dependencies = [
"tracing",
]
[[package]]
name = "ide-remote"
version = "0.1.22"
dependencies = [
"globset",
"ide-core",
"ide-run",
"ide-ssh",
"ignore",
"parking_lot",
"serde",
"specta",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "ide-run"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"encoding_rs",
"ide-core",
"libc",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4256,27 +4353,31 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"zip 2.4.2",
]
[[package]]
name = "ide-sdk"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"dunce",
"ide-core",
"ide-language",
"ide-run",
"ide-vfs",
"ignore",
"serde",
"serde_json",
"specta",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip 2.4.2",
]
[[package]]
name = "ide-search"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"criterion",
"globset",
@@ -4284,6 +4385,7 @@ dependencies = [
"grep-regex",
"grep-searcher",
"ide-core",
"ide-vfs",
"ignore",
"insta",
"memchr",
@@ -4293,9 +4395,41 @@ dependencies = [
"tracing",
]
[[package]]
name = "ide-spell"
version = "0.1.22"
dependencies = [
"ide-core",
"parking_lot",
"serde",
"serde_json",
"specta",
"spellbook",
"tracing",
]
[[package]]
name = "ide-ssh"
version = "0.1.22"
dependencies = [
"dirs 6.0.0",
"flate2",
"ide-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "ide-syntax"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"ide-language",
@@ -4310,7 +4444,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"ide-core",
"libc",
@@ -4323,7 +4457,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"criterion",
"ide-core",
@@ -4335,7 +4469,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"flate2",
"hex",
@@ -4355,7 +4489,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"dunce",
"gix",
@@ -4372,7 +4506,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"chardetng",
"dunce",
@@ -4388,11 +4522,28 @@ dependencies = [
"tempfile",
"tracing",
"trash",
"zip 2.4.2",
]
[[package]]
name = "ide-web"
version = "0.1.22"
dependencies = [
"ide-core",
"open",
"parking_lot",
"percent-encoding",
"serde",
"specta",
"tempfile",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "ide-workspace"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"blake3",
"dunce",
@@ -4409,6 +4560,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"zip 2.4.2",
]
[[package]]
@@ -5816,33 +5968,42 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.2"
version = "0.1.22"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-agent",
"ide-analysis",
"ide-build",
"ide-clipboard",
"ide-core",
"ide-coverage",
"ide-dap",
"ide-db",
"ide-docker",
"ide-github",
"ide-host",
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-plugin",
"ide-profiler",
"ide-remote",
"ide-run",
"ide-sdk",
"ide-search",
"ide-spell",
"ide-ssh",
"ide-syntax",
"ide-terminal",
"ide-text",
"ide-tools",
"ide-vcs",
"ide-vfs",
"ide-web",
"ide-workspace",
"parking_lot",
"serde",
@@ -6927,6 +7088,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "russh-sftp"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b"
dependencies = [
"bitflags 2.13.2",
"bytes",
"chrono",
"dashmap",
"gloo-timers",
"log",
"serde",
"serde_bytes",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"wasm-bindgen-futures",
]
[[package]]
name = "russh-util"
version = "0.52.0"
@@ -7260,6 +7441,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -7701,6 +7892,16 @@ dependencies = [
"specta",
]
[[package]]
name = "spellbook"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.17.1",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -9221,6 +9422,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-gdscript"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a37fe8c0a10c0c39ecd5b2f7db53933f691488f5572409a4d3c0dfeb3f6108"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.25.0"
+13 -3
Переглянути файл
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.2"
version = "0.1.22"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -32,6 +32,13 @@ ide-coverage = { path = "crates/ide-coverage" }
ide-profiler = { path = "crates/ide-profiler" }
ide-http = { path = "crates/ide-http" }
ide-db = { path = "crates/ide-db" }
ide-web = { path = "crates/ide-web" }
ide-spell = { path = "crates/ide-spell" }
ide-agent = { path = "crates/ide-agent" }
ide-clipboard = { path = "crates/ide-clipboard" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -39,6 +46,7 @@ serde_json = "1"
toml = "0.9"
toml_edit = "0.25"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
specta-typescript = "0.0.12"
# async & concurrency
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] }
@@ -100,5 +108,7 @@ opt-level = 2
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
# Keep the symbol table: crash reports and logged backtraces name functions.
strip = "debuginfo"
+4
Переглянути файл
@@ -6,6 +6,10 @@ contributors as part of the IntelliJ Platform (https://github.com/JetBrains/inte
https://github.com/JetBrains/intellij-plugins), licensed under the Apache License 2.0.
Copyright 2000-2026 JetBrains s.r.o. and contributors.
Some Simplified Chinese UI terms follow the Visual Studio Code Simplified Chinese
language pack (https://github.com/microsoft/vscode-loc), Copyright (c) Microsoft
Corporation, licensed under the MIT License.
OpenIDEA is not affiliated with, endorsed by or sponsored by JetBrains s.r.o.
IntelliJ and IntelliJ IDEA are trademarks of JetBrains s.r.o.
+29 -1
Переглянути файл
@@ -9,13 +9,41 @@
The IDE you know, rebuilt in Rust.
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool windows,
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages.
for 60+ languages. Builds for macOS, Windows and Linux.
## Benchmark
Opening [freqtrade](https://github.com/freqtrade/freqtrade) (Python, ~770 files)
with warm caches, median of 5 runs:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| --------------------------- | ----------------- | ---------------------- |
| Window with the project | **0.9 s** | 3.1 s |
| Ready (CPU settled) | **1.1 s** | 13.2 s |
| Memory (USS, all processes) | **169 MB** | 2011 MB |
| CPU time until ready | **2.4 s** | 47.9 s |
| Idle CPU | **4 %** of a core | 7 % of a core |
| Quit | **0.3 s** | 5.9 s |
A release build of OpenIDEA from `main` and IDEA with default settings and plugins, on
Windows 11, 32-core AMD, 62 GB RAM.
IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
+141 -1
Переглянути файл
@@ -79,6 +79,25 @@ user's app-data directory only when the user asks; none of them ships with OpenI
unmodified, and its source is available from the repository above.
- **Eclipse JDT Language Server** (jdtls): EPL-2.0, from download.eclipse.org.
## en_US / en_GB Hunspell dictionaries
`crates/ide-spell/src/dict/en_US.{aff,dic}` (the spell checker's bundled dictionary) is the
SCOWL-derived en_US Hunspell dictionary (version 2020.12.07, from the `dictionary-en` npm
package 4.0.0), copied unmodified. Copyright Kevin Atkinson and the SCOWL / Hunspell
dictionary contributors; MIT- and BSD-style licences, full text in
`crates/ide-spell/src/dict/LICENSE-en_US`. `crates/ide-spell/src/dict/en_GB-extra.dic` holds the
entries of the matching en_GB dictionary (`dictionary-en-gb` npm package 3.0.0, same SCOWL
version, licence and affix file) that en_US lacks. They are checked with the
[spellbook](https://github.com/helix-editor/spellbook) crate (MPL-2.0).
## cspell-dicts word lists
`crates/ide-spell/src/dict/cspell-terms.txt` (programming terms the spell checker accepts)
is derived from the software-terms, typescript, node, rust, fullstack, golang, java, cpp,
shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts)
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
@@ -97,7 +116,7 @@ Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
@@ -148,3 +167,124 @@ If the Work includes a "NOTICE" text file as part of its distribution, then any
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
## Solarized
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
See [theme sources and mapping](docs/themes.md). MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## Atom One Light and One Dark
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Both syntax themes use the following MIT notice:
```text
Copyright (c) 2016 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
Both UI themes use the following MIT notice:
```text
Copyright (c) 2014 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
## Chinese UI terminology from Visual Studio Code
Some Simplified Chinese terms in `src/platform/locales/text/zh.json` follow the
wording of the Visual Studio Code Simplified Chinese language pack
([microsoft/vscode-loc](https://github.com/microsoft/vscode-loc), MIT), e.g.
"未跟踪" (untracked), "合并冲突" (merge conflicts), "当前" (current), "已修改"
(modified), "已过时" (outdated), "中止" (abort), "正在卸载" (uninstalling),
"深色" / "浅色" (dark / light) and "只读" (read-only). The remaining translations
are written for this project.
```
Visual Studio Code Localization Packs
Copyright (c) Microsoft Corporation
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
```
+22
Переглянути файл
@@ -0,0 +1,22 @@
[package]
name = "ide-agent"
description = "Coding agents over the Agent Client Protocol: tasks, sessions, worktrees and their timelines"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
parking_lot.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
+217
Переглянути файл
@@ -0,0 +1,217 @@
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
//! the agent reports them over ACP.
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use serde_json::{Value, json};
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
let mut out = result.get("configOptions").map(parse_options).unwrap_or_default();
let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category));
if !has(&out, "mode")
&& let Some(modes) = result.get("modes")
{
out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode));
}
if !has(&out, "model")
&& let Some(models) = result.get("models")
{
out.push(legacy(
models,
"model",
"Model",
"availableModels",
"modelId",
"currentModelId",
AgentConfigVia::Model,
));
}
out.retain(|o| !o.values.is_empty());
out
}
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
let Some(list) = options.as_array() else { return Vec::new() };
list.iter()
.filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select"))
.filter_map(|o| {
let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned();
let mut values = Vec::new();
for v in o.get("options").and_then(Value::as_array).into_iter().flatten() {
// Grouped options: `{ group, name, options: [...] }`.
match v.get("options").and_then(Value::as_array) {
Some(group) => values.extend(group.iter().filter_map(value_of)),
None => values.extend(value_of(v)),
}
}
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
id,
})
})
.collect()
}
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
/// stand-ins stay.
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
let legacy = std::mem::take(config);
config.extend(fresh);
config.extend(legacy);
config.retain(|o| !o.values.is_empty());
}
/// `current_mode_update`.
pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) {
if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) {
o.current = Some(mode.to_owned());
}
}
/// The request (method, params without the session id) that sets `option` to `value`.
pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) {
match option.via {
AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })),
AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })),
AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })),
}
}
/// `available_commands_update`.
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
update
.get("availableCommands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
Some(AgentCommand {
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
description: str_of(c, "description").unwrap_or_default(),
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
})
})
.collect()
}
/// `usage_update`: `{ used, size }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
}
fn legacy(
v: &Value,
id: &str,
name: &str,
list: &str,
key: &str,
current: &str,
via: AgentConfigVia,
) -> AgentConfigOption {
let values = v
.get(list)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|m| {
let value = m.get(key)?.as_str()?.to_owned();
Some(AgentConfigValue {
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
description: str_of(m, "description"),
value,
})
})
.collect();
AgentConfigOption {
id: id.to_owned(),
name: name.to_owned(),
description: None,
category: Some(id.to_owned()),
current: str_of(v, current),
values,
via,
}
}
fn value_of(v: &Value) -> Option<AgentConfigValue> {
let value = v.get("value")?.as_str()?.to_owned();
Some(AgentConfigValue {
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
description: str_of(v, "description"),
value,
})
}
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> {
c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect()
}
#[test]
fn config_options_win_over_the_older_fields() {
let r = json!({
"modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]},
"configOptions": [
{"id": "mode", "category": "mode", "type": "select", "currentValue": "plan",
"options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]},
{"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium",
"options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]},
{"id": "fast", "type": "boolean", "currentValue": false}
],
"models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]}
});
let c = options_of(&r);
assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]);
assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["low", "Medium"]);
assert_eq!(set_request(&c[0], "default").0, "session/set_config_option");
assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"})));
}
#[test]
fn updates_replace_options_and_track_the_mode() {
let mut c = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]},
"configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}]
}));
assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id");
set_mode(&mut c, "code");
assert_eq!(c[1].current.as_deref(), Some("code"));
assert_eq!(set_request(&c[1], "ask").0, "session/set_mode");
merge_update(
&mut c,
&json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]),
);
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
assert_eq!((u.used, u.size), (1200, 200000));
assert!(usage_of(&json!({"used": 1, "size": 0})).is_none());
}
}
+281
Переглянути файл
@@ -0,0 +1,281 @@
//! A task's conversation and those of its subagents.
//!
//! An agent that supports ACP subagent sessions runs each subagent (Claude Code's Agent
//! tool, Codex's spawned agents) as a child session: `subagent_update` on the parent
//! announces it and reports its state, its own updates carry its session id, and
//! `session_message`s carry what it was asked and what it answered. The parent's
//! timeline shows a card per subagent; each subagent keeps its own timeline.
use crate::model::AgentEntry;
use crate::timeline::{Timeline, blocks_text, content_text};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Where an entry lives: the task's own conversation (`None`) or a subagent's session.
pub(crate) type Session = Option<String>;
#[derive(Default)]
pub(crate) struct Conversation {
pub main: Timeline,
/// In the order they started.
subagents: Vec<Subagent>,
}
struct Subagent {
id: String,
/// The session that started it.
parent: Session,
timeline: Timeline,
}
/// A subagent's conversation, as saved with its task.
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedSubagent {
pub id: String,
pub parent: Option<String>,
pub entries: Vec<AgentEntry>,
}
impl Conversation {
pub fn restore(main: Vec<AgentEntry>, subagents: Vec<SavedSubagent>) -> Self {
Self {
main: Timeline::from_entries(main),
subagents: subagents
.into_iter()
.map(|s| Subagent { id: s.id, parent: s.parent, timeline: Timeline::from_entries(s.entries) })
.collect(),
}
}
pub fn saved_subagents(&self) -> Vec<SavedSubagent> {
self.subagents
.iter()
.map(|s| SavedSubagent { id: s.id.clone(), parent: s.parent.clone(), entries: s.timeline.entries.clone() })
.collect()
}
pub fn timeline(&self, session: &Session) -> Option<&Timeline> {
match session {
None => Some(&self.main),
Some(id) => self.subagents.iter().find(|s| &s.id == id).map(|s| &s.timeline),
}
}
fn timeline_mut(&mut self, session: &Session) -> Option<&mut Timeline> {
match session {
None => Some(&mut self.main),
Some(id) => self.subagents.iter_mut().find(|s| &s.id == id).map(|s| &mut s.timeline),
}
}
fn parent_of(&self, id: &str) -> Option<&Session> {
self.subagents.iter().find(|s| s.id == id).map(|s| &s.parent)
}
/// The session an update for session id `session` belongs to: a known subagent's, else the task's.
pub fn owner(&self, session: Option<&str>) -> Session {
session.filter(|s| self.parent_of(s).is_some()).map(str::to_owned)
}
/// The parent's title (or description) for subagent `id`.
pub fn label(&self, id: &str) -> Option<String> {
let parent = self.parent_of(id)?;
self.timeline(parent)?.entries.iter().find_map(|e| match e {
AgentEntry::Subagent { id: card, title, description, .. } if card == id => {
title.clone().or_else(|| description.clone())
}
_ => None,
})
}
/// Applies one `session/update` of session id `session` (the task's own when unknown);
/// returns the entries it added or changed.
pub fn apply(&mut self, session: Option<&str>, update: &Value, now: f64) -> Vec<(Session, usize)> {
let owner = self.owner(session);
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
let mut out = Vec::new();
match kind {
"subagent_update" => {
let Some(id) = update.get("sessionId").and_then(Value::as_str) else { return out };
if self.parent_of(id).is_none() {
self.subagents.push(Subagent { id: id.to_owned(), parent: owner, timeline: Timeline::default() });
}
let parent = self.parent_of(id).cloned().flatten();
let Some(timeline) = self.timeline_mut(&parent) else { return out };
let (index, stopped) = timeline.subagent(id, update, now);
out.push((parent, index));
if stopped && let Some(child) = self.timeline_mut(&Some(id.to_owned())) {
let id = Some(id.to_owned());
out.extend(child.end_turn(None).into_iter().map(|i| (id.clone(), i)));
}
}
"session_message" | "session_message_chunk" => {
let append = kind == "session_message_chunk";
let text = if append {
content_text(update.get("content")).unwrap_or_default()
} else {
blocks_text(update.get("content"))
};
let field = |k: &str| update.get(k).and_then(Value::as_str).map(str::to_owned);
let (sender, recipient) = (field("senderSessionId"), field("recipientSessionId"));
let ours =
|s: &Option<String>| s.as_deref().filter(|s| self.parent_of(s) == Some(&owner)).map(str::to_owned);
// Between this session and a subagent of it: the subagent's task or answer, on its card.
let card = ours(&recipient).map(|c| (c, false)).or_else(|| ours(&sender).map(|c| (c, true)));
let Some(timeline) = self.timeline_mut(&owner) else { return out };
let index = match card {
Some((child, reply)) => timeline.card_message(&child, reply, &text, append),
None => {
let id = field("messageId").unwrap_or_default();
Some(timeline.message(&id, sender != owner, &text, append))
}
};
out.extend(index.map(|i| (owner, i)));
}
_ => {
let Some(timeline) = self.timeline_mut(&owner) else { return out };
out.extend(timeline.apply(update).changed.into_iter().map(|i| (owner.clone(), i)));
if let Some(id) = owner.as_deref()
&& matches!(kind, "tool_call" | "tool_call_update")
{
out.extend(self.refresh_card(id));
}
}
}
out
}
/// Updates subagent `id`'s card from its timeline.
fn refresh_card(&mut self, id: &str) -> Option<(Session, usize)> {
let parent = self.parent_of(id)?.clone();
let child = self.subagents.iter().position(|s| s.id == id)?;
let timeline = std::mem::take(&mut self.subagents[child].timeline);
let changed = self.timeline_mut(&parent).and_then(|p| p.card_activity(id, &timeline));
self.subagents[child].timeline = timeline;
changed.map(|i| (parent, i))
}
/// Settles what an ended turn of the task left open. Subagents work on (in the background).
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<(Session, usize)> {
self.main.end_turn(done).into_iter().map(|i| (None, i)).collect()
}
/// Settles everything left open once the agent is gone, subagents included.
pub fn settle(&mut self, now: f64) -> Vec<(Session, usize)> {
let mut out: Vec<(Session, usize)> = self.main.end_turn(None).into_iter().map(|i| (None, i)).collect();
out.extend(self.main.stop_subagents(now).into_iter().map(|i| (None, i)));
for s in &mut self.subagents {
let id = Some(s.id.clone());
let mut changed = s.timeline.end_turn(None);
changed.extend(s.timeline.stop_subagents(now));
out.extend(changed.into_iter().map(|i| (id.clone(), i)));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AgentSubagentStatus, AgentToolStatus};
use serde_json::json;
fn card(c: &Conversation, session: &Session, index: usize) -> AgentEntry {
c.timeline(session).unwrap().entries[index].clone()
}
/// Claude Code's Agent tool, as `claude-agent-acp` reports it to a client with subagent sessions.
#[test]
fn a_subagent_gets_a_card_in_its_parent_and_its_own_timeline() {
let mut c = Conversation::default();
let sub = Some("child".to_owned());
let announce = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "title": "Explore",
"description": "Find the parser", "capabilities": {"cancel": {}}, "state": {"state": "running"}});
assert_eq!(c.apply(Some("root"), &announce, 10.0), vec![(None, 0)]);
// The launch prompt: the parent's outgoing view, then the child's incoming one.
let prompt = json!({"sessionUpdate": "session_message", "messageId": "toolu_1",
"senderSessionId": "root", "recipientSessionId": "child",
"content": [{"type": "text", "text": "Look for it"}]});
assert_eq!(c.apply(Some("root"), &prompt, 11.0), vec![(None, 0)]);
assert_eq!(c.apply(Some("child"), &prompt, 11.0), vec![(sub.clone(), 0)]);
assert_eq!(card(&c, &sub, 0), AgentEntry::Message { incoming: true, text: "Look for it".into() });
// The child's own work stays out of the parent's timeline; its card follows it.
c.apply(
Some("child"),
&json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hm"}}),
12.0,
);
let changed = c.apply(
Some("child"),
&json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Read lib.rs", "kind": "read", "status": "in_progress"}),
12.0,
);
assert_eq!(changed, vec![(sub.clone(), 2), (None, 0)]);
assert_eq!(c.main.entries.len(), 1);
// Settings of the child session don't touch the task.
assert_eq!(c.owner(Some("child")), sub);
assert_eq!(c.owner(Some("root")), None);
let done = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "state": {"state": "idle", "stopReason": "end_turn"}});
let changed = c.apply(Some("root"), &done, 20.0);
// Its tool calls left open are settled when it stops.
assert_eq!(changed, vec![(None, 0), (sub.clone(), 2)]);
let reply = json!({"sessionUpdate": "session_message", "messageId": "toolu_1:reply", "senderSessionId": "child",
"recipientSessionId": "root", "content": [{"type": "text", "text": "In "}, {"type": "text", "text": "parser.rs"}]});
c.apply(Some("root"), &reply, 20.0);
assert_eq!(
c.main.entries[0],
AgentEntry::Subagent {
id: "child".into(),
title: Some("Explore".into()),
description: Some("Find the parser".into()),
status: AgentSubagentStatus::Done,
error: None,
prompt: Some("Look for it".into()),
reply: Some("In \nparser.rs".into()),
tools: 1,
activity: Some("Read lib.rs".into()),
started_ms: 10.0,
ended_ms: Some(20.0),
cancellable: true,
}
);
assert_eq!(c.label("child").as_deref(), Some("Explore"));
}
#[test]
fn nested_subagents_and_failures() {
let mut c = Conversation::default();
c.apply(
None,
&json!({"sessionUpdate": "subagent_update", "sessionId": "a", "state": {"state": "running"}}),
1.0,
);
c.apply(
Some("a"),
&json!({"sessionUpdate": "subagent_update", "sessionId": "b", "description": "deeper"}),
2.0,
);
let a = Some("a".to_owned());
assert!(matches!(card(&c, &a, 0), AgentEntry::Subagent { ref id, .. } if id == "b"));
let failed = json!({"sessionUpdate": "subagent_update", "sessionId": "b",
"state": {"state": "idle", "stopReason": "error", "error": {"code": -32603, "message": "boom"}}});
assert_eq!(c.apply(Some("a"), &failed, 3.0), vec![(a.clone(), 0)]);
assert!(matches!(card(&c, &a, 0),
AgentEntry::Subagent { status: AgentSubagentStatus::Failed, error: Some(ref e), ended_ms: Some(3.0), .. } if e == "boom"));
// Saved and restored, then the agent is gone: what still works is stopped.
let mut c = Conversation::restore(c.main.entries.clone(), c.saved_subagents());
c.apply(
Some("a"),
&json!({"sessionUpdate": "tool_call", "toolCallId": "t", "title": "Run", "status": "in_progress"}),
4.0,
);
let settled = c.settle(9.0);
assert_eq!(settled, vec![(None, 0), (a.clone(), 1)]);
assert!(matches!(c.main.entries[0], AgentEntry::Subagent { status: AgentSubagentStatus::Cancelled, .. }));
assert!(matches!(card(&c, &a, 1), AgentEntry::Tool { status: AgentToolStatus::Failed, .. }));
}
}
File diff suppressed because it is too large. Load diff
+328
Переглянути файл
@@ -0,0 +1,328 @@
//! IPC types: tasks, their timeline entries and the events streamed to the UI.
use ide_core::IpcPath;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentStatus {
/// Creating the worktree / starting the agent.
Starting,
Running,
/// Waiting for a permission answer.
NeedsInput,
/// The turn ended; the result can be reviewed or the conversation continued.
Ready,
Failed,
Archived,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Isolation {
/// A new git worktree on its own branch.
Worktree,
/// The project's own checkout.
Local,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentTask {
pub id: String,
pub title: String,
/// [`AgentProvider::id`].
pub provider: String,
/// Model the task runs on (its `model` setting), when known.
pub model: Option<String>,
pub status: AgentStatus,
pub isolation: Isolation,
/// Directory the agent works in (the worktree, or the project root).
pub cwd: IpcPath,
/// Branch of the task's worktree.
pub branch: Option<String>,
/// Branch (or revision) the worktree was created from.
pub base: Option<String>,
/// Commit the task's changes are compared against (worktree tasks; local ones use `HEAD`).
pub base_commit: Option<String>,
/// Why the task failed, when it did.
pub error: Option<String>,
pub created_ms: f64,
pub updated_ms: f64,
/// The session's settings (model, mode, thinking effort…) as last reported, with the
/// user's choices applied.
pub config: Vec<AgentConfigOption>,
/// Slash commands the agent offers.
pub commands: Vec<AgentCommand>,
/// Context window use, when the agent reports it.
pub usage: Option<AgentUsage>,
/// Messages waiting for the current turn to end, oldest first.
pub queue: Vec<AgentQueued>,
}
/// One value of a select-type session setting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfigValue {
pub value: String,
pub name: String,
pub description: Option<String>,
}
/// How a setting is changed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentConfigVia {
/// `session/set_config_option`.
#[default]
Option,
/// `session/set_mode` (agents that report `modes` instead of a mode config option).
Mode,
/// `session/set_model` (agents that report `models` instead of a model config option).
Model,
}
/// A session setting: an ACP select config option, or the older `modes` / `models`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfigOption {
pub id: String,
pub name: String,
pub description: Option<String>,
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
pub category: Option<String>,
pub current: Option<String>,
pub values: Vec<AgentConfigValue>,
pub via: AgentConfigVia,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommand {
/// Without the leading `/`.
pub name: String,
pub description: String,
/// What to type after the command, when it takes input.
pub hint: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentUsage {
/// Tokens in the context window.
pub used: u32,
/// Size of the context window.
pub size: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentImage {
pub mime_type: String,
/// Base64, without a `data:` prefix.
pub data: String,
pub name: Option<String>,
}
/// A message to the agent: text, plus files to look at and images.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentPrompt {
pub text: String,
pub files: Vec<IpcPath>,
pub images: Vec<AgentImage>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQueued {
pub id: String,
pub prompt: AgentPrompt,
}
/// A setting chosen for a new task, by [`AgentConfigOption::id`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfigChoice {
pub id: String,
pub value: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentToolStatus {
Pending,
Running,
Completed,
Failed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentToolDiff {
pub path: IpcPath,
pub added: u32,
pub removed: u32,
}
/// What a subagent is doing, as its parent reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentSubagentStatus {
Running,
/// One of its requests (a permission) waits for an answer.
NeedsInput,
Done,
Cancelled,
Failed,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentPlanItem {
pub content: String,
/// `pending`, `in_progress` or `completed`.
pub status: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentPermissionOption {
pub id: String,
pub name: String,
/// `allow_once`, `allow_always`, `reject_once` or `reject_always`.
pub kind: String,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum AgentEntry {
User {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
/// When the turn it started ended; `None` while it runs or when it was cut off.
#[serde(default)]
done_ms: Option<f64>,
},
Agent {
text: String,
},
Thought {
text: String,
},
Tool {
id: String,
title: String,
/// ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`…
kind: String,
status: AgentToolStatus,
locations: Vec<IpcPath>,
/// Text the tool reported (command output…), truncated.
output: Option<String>,
diffs: Vec<AgentToolDiff>,
},
Plan {
items: Vec<AgentPlanItem>,
},
/// A subagent the agent started: its own session, whose entries are listed separately.
Subagent {
/// The subagent's session id.
id: String,
title: Option<String>,
description: Option<String>,
status: AgentSubagentStatus,
error: Option<String>,
/// What it was asked to do, last.
prompt: Option<String>,
/// Its answer.
reply: Option<String>,
/// Tool calls it made.
tools: u32,
/// Title of its latest tool call.
activity: Option<String>,
started_ms: f64,
/// When it last stopped working; `None` while it works.
ended_ms: Option<f64>,
/// Whether it can be stopped on its own.
cancellable: bool,
},
/// A message between sessions in a subagent's conversation: its task (`incoming`) or its report.
Message {
incoming: bool,
text: String,
},
Permission {
id: u32,
title: String,
options: Vec<AgentPermissionOption>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
Error {
message: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentProvider {
pub id: String,
pub name: String,
/// The command line that starts its ACP server.
pub command: String,
/// Whether the command was found (on `PATH`).
pub installed: bool,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NewAgentTask {
pub prompt: AgentPrompt,
pub provider: String,
/// Settings for the new session (model, mode…).
pub config: Vec<AgentConfigChoice>,
pub isolation: Isolation,
/// Branch to start the worktree from; the current branch when `None`.
pub base: Option<String>,
}
/// A file the task changed, relative to [`AgentTask::base_commit`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentChange {
pub path: IpcPath,
pub rel_path: String,
pub kind: ide_vcs::ChangeKind,
pub added: u32,
pub removed: u32,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum AgentEvent {
/// A task was created or its metadata changed.
Task {
task: AgentTask,
},
Removed {
id: String,
},
/// Entry `index` of `task` (of its subagent `subagent`) was added or replaced.
Entry {
task: String,
subagent: Option<String>,
index: u32,
entry: AgentEntry,
},
}
+158
Переглянути файл
@@ -0,0 +1,158 @@
//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined
//! agents from `<config>/agents/<id>.toml`.
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ProviderSpec {
pub id: String,
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
/// How to sign in when the agent reports it needs authentication.
pub login_hint: String,
}
/// `(id, name, command line, sign-in hint)`. npm adapters are pinned so a session's
/// protocol does not change under the user.
const BUILTIN: &[(&str, &str, &[&str], &str)] = &[
(
"claude",
"Claude Code",
&["npx", "-y", "@agentclientprotocol/[email protected]"],
"Run `claude` in a terminal and sign in.",
),
("codex", "Codex", &["npx", "-y", "@agentclientprotocol/[email protected]"], "Run `codex login` in a terminal."),
("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."),
("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."),
("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
("opencode", "OpenCode", &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
("qwen", "Qwen Code", &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
("kimi", "Kimi CLI", &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
("goose", "Goose", &["goose", "acp"], "Run `goose configure` in a terminal."),
("auggie", "Auggie", &["auggie", "--acp"], "Run `auggie login` in a terminal."),
("kiro", "Kiro", &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
("vibe", "Mistral Vibe", &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
];
fn builtins() -> Vec<ProviderSpec> {
BUILTIN
.iter()
.map(|(id, name, cmd, hint)| ProviderSpec {
id: (*id).into(),
name: (*name).into(),
command: cmd[0].into(),
args: cmd[1..].iter().map(|a| (*a).to_owned()).collect(),
env: Vec::new(),
login_hint: (*hint).into(),
})
.collect()
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CustomAgent {
name: Option<String>,
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
/// built-in id replaces it.
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
let mut specs = builtins();
let mut files: Vec<PathBuf> = custom_dir
.and_then(|d| std::fs::read_dir(d).ok())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.collect();
files.sort();
for path in files {
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let parsed = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| toml::from_str::<CustomAgent>(&t).map_err(|e| e.to_string()));
let agent = match parsed {
Ok(a) => a,
Err(e) => {
tracing::warn!("skipping agent {}: {e}", path.display());
continue;
}
};
let mut env: Vec<(String, String)> = agent.env.into_iter().collect();
env.sort();
let spec = ProviderSpec {
name: agent.name.unwrap_or_else(|| id.clone()),
command: agent.command,
args: agent.args,
env,
login_hint: "Sign in with the agent's own command line first.".into(),
id,
};
match specs.iter_mut().find(|s| s.id == spec.id) {
Some(existing) => *existing = spec,
None => specs.push(spec),
}
}
specs
}
/// Whether `command` resolves to an executable (a path, or a name on `PATH`).
pub(crate) fn installed(command: &str) -> bool {
let p = Path::new(command);
if p.components().count() > 1 {
return is_executable(p);
}
let exts: &[&str] = if cfg!(windows) { &["exe", "cmd", "bat"] } else { &[] };
std::env::var_os("PATH").is_some_and(|path| {
std::env::split_paths(&path).any(|dir| {
let c = dir.join(command);
is_executable(&c) || exts.iter().any(|e| is_executable(&c.with_extension(e)))
})
})
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_agents_extend_and_override_the_builtins() {
let dir = tempfile::tempdir().unwrap();
let write = |name: &str, text: &str| std::fs::write(dir.path().join(name), text).unwrap();
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
write("broken.toml", "nope = 1\n");
let specs = load(Some(dir.path()));
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
let gemini = specs.iter().find(|s| s.id == "gemini").unwrap();
assert_eq!((gemini.name.as_str(), gemini.args.as_slice()), ("gemini", &["--acp".to_owned()][..]));
assert!(!specs.iter().any(|s| s.id == "broken"));
assert_eq!(specs.iter().filter(|s| s.id == "claude").count(), 1);
assert!(!installed("/definitely/not/here"));
assert!(installed("sh"));
}
}
+281
Переглянути файл
@@ -0,0 +1,281 @@
//! Claude Code's slash commands on disk, for completion before (or without) a session:
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::AgentCommand;
use std::collections::HashSet;
use std::path::Path;
const MAX_DESCRIPTION: usize = 200;
/// Command directories nest this deep at most (`a/b/c.md` → `a:b:c`).
const MAX_DEPTH: usize = 4;
/// The commands Claude Code finds from `cwd`: nearest directory first, the user's last;
/// the first of a name wins.
pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
let mut out = Vec::new();
let mut seen = HashSet::new();
let mut add = |found: Vec<AgentCommand>| {
for c in found {
if seen.insert(c.name.clone()) {
out.push(c);
}
}
};
for dir in cwd.ancestors() {
if home.is_some_and(|h| h == dir) {
break;
}
add(in_dir(&dir.join(".claude")));
}
if let Some(home) = home {
add(in_dir(&home.join(".claude")));
}
out
}
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
out
}
fn in_dir(claude: &Path) -> Vec<AgentCommand> {
if !claude.is_dir() {
return Vec::new();
}
let mut out = skills(&claude.join("skills"));
commands(&claude.join("commands"), "", 0, &mut out);
out
}
fn skills(dir: &Path) -> Vec<AgentCommand> {
let mut out: Vec<AgentCommand> = sorted(dir)
.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(path.join("SKILL.md")).ok()?;
let meta = Frontmatter::parse(&text);
if meta.get("user-invocable").is_some_and(|v| v.eq_ignore_ascii_case("false")) {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>) {
if depth > MAX_DEPTH {
return;
}
for path in sorted(dir) {
let Some(name) = file_name(&path) else { continue };
if path.is_dir() {
commands(&path, &format!("{prefix}{name}:"), depth + 1, out);
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
}
}
}
fn sorted(dir: &Path) -> Vec<std::path::PathBuf> {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| file_name(p).is_some_and(|n| !n.starts_with('.')))
.collect();
paths.sort();
paths
}
fn file_name(path: &Path) -> Option<String> {
path.file_name()?.to_str().map(str::to_owned)
}
/// The `key: value` lines between the leading `---` fences (folded `>` / `|` values joined).
struct Frontmatter {
fields: Vec<(String, String)>,
/// Where the body starts.
body: usize,
}
impl Frontmatter {
fn parse(text: &str) -> Self {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut lines = text.split_inclusive('\n');
let mut offset = 0;
let mut fields: Vec<(String, String)> = Vec::new();
match lines.next() {
Some(first) if first.trim_end() == "---" => offset += first.len(),
_ => return Self { fields, body: 0 },
}
for line in lines {
offset += line.len();
let line = line.trim_end();
if line == "---" {
return Self { fields, body: offset };
}
if line.starts_with([' ', '\t']) {
if let Some((_, value)) = fields.last_mut() {
if !value.is_empty() {
value.push(' ');
}
value.push_str(line.trim());
}
continue;
}
if let Some((key, value)) = line.split_once(':') {
let value = value.trim();
let value = if matches!(value, ">" | "|" | ">-" | "|-") { "" } else { unquote(value) };
fields.push((key.trim().to_owned(), value.to_owned()));
}
}
// No closing fence: not frontmatter.
Self { fields: Vec::new(), body: 0 }
}
fn get(&self, key: &str) -> Option<&str> {
self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
.map(|l| l.trim().trim_start_matches('#').trim())
.find(|l| !l.is_empty())
.unwrap_or_default()
.to_owned()
});
AgentCommand {
name,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
}
}
fn unquote(value: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) {
return inner;
}
}
value
}
fn truncate(s: &str) -> String {
match s.char_indices().nth(MAX_DESCRIPTION) {
Some((i, _)) => format!("{}…", s[..i].trim_end()),
None => s.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn names(cmds: &[AgentCommand]) -> Vec<&str> {
cmds.iter().map(|c| c.name.as_str()).collect()
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand { name: name.into(), description: description.into(), hint: None }
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
&skills.join("triage/SKILL.md"),
"---\nname: triage\ndescription: One pass over open issues and PRs\n---\n# Triage\n",
);
write(&skills.join("issue-fix/SKILL.md"), "---\nname: issue-fix\ndescription: \"Fix an issue\"\n---\n");
// No frontmatter name: the directory's.
write(&skills.join("verify/SKILL.md"), "---\ndescription: >\n Build and\n drive the app\n---\n");
write(&skills.join("hidden/SKILL.md"), "---\nname: hidden\nuser-invocable: false\n---\n");
write(&skills.join("no-skill-file/README.md"), "nothing");
write(&repo.join(".claude/commands/review.md"), "---\nargument-hint: <pr>\n---\nReview a pull request\n");
write(&repo.join(".claude/commands/git/commit.md"), "# Commit the staged changes\n");
// A parent directory's commands apply too.
write(&tmp.path().join("work/.claude/commands/parent.md"), "---\ndescription: from above\n---\n");
// The user's: shadowed by the project's skill of the same name.
write(&home.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: user triage\n---\n");
write(&home.join(".claude/skills/lint/SKILL.md"), "---\nname: lint\ndescription: user lint\n---\n");
write(&home.join(".claude/commands/brooks-review.md"), "---\ndescription: Review\n---\n");
let found = discover(&repo, Some(&home));
assert_eq!(
names(&found),
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
assert_eq!(by("git:commit").description, "Commit the staged changes");
assert_eq!(
(by("review").description.as_str(), by("review").hint.as_deref()),
("Review a pull request", Some("<pr>"))
);
}
#[test]
fn nothing_without_claude_dirs() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo = home.join("plain");
std::fs::create_dir_all(&repo).unwrap();
assert!(discover(&repo, Some(&home)).is_empty());
}
#[test]
fn walking_up_stops_at_home() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
// Above home: not the user's, and not this project's.
write(&tmp.path().join(".claude/commands/outside.md"), "x\n");
write(&home.join(".claude/commands/mine.md"), "x\n");
let repo = home.join("src/app");
std::fs::create_dir_all(&repo).unwrap();
assert_eq!(names(&discover(&repo, Some(&home))), ["mine"]);
}
#[test]
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
}
#[test]
fn frontmatter_edge_cases() {
let f = Frontmatter::parse("no fence\n");
assert!(f.fields.is_empty());
let f = Frontmatter::parse("---\nname: x\nunterminated\n");
assert!(f.get("name").is_none());
let f = Frontmatter::parse("\u{feff}---\r\nname: 'quoted'\r\n---\r\nbody\r\n");
assert_eq!(f.get("name"), Some("quoted"));
assert_eq!(truncate(&"é".repeat(MAX_DESCRIPTION + 5)).chars().count(), MAX_DESCRIPTION + 1);
}
}
+590
Переглянути файл
@@ -0,0 +1,590 @@
//! End-to-end task flow against an in-memory ACP agent.
use super::*;
use std::sync::OnceLock;
use tokio::io::duplex;
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
#[derive(Default)]
struct FakeAgent {
peer: OnceLock<Peer>,
calls: Mutex<Vec<String>>,
}
impl FakeAgent {
fn update(&self, session: &str, update: Value) {
let peer = self.peer.get().unwrap();
peer.notify("session/update", json!({ "sessionId": session, "update": update })).unwrap();
}
fn say(&self, session: &str, text: &str) {
self.update(
session,
json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}),
);
}
}
impl FakeAgent {
/// Runs a subagent in session `c1`, as an agent with ACP subagent sessions reports it.
fn delegate(&self, root: &str) {
let sub = |state: Value| json!({"sessionUpdate": "subagent_update", "sessionId": "c1", "state": state});
let mut announce = sub(json!({"state": "running"}));
announce["title"] = json!("Explore");
self.update(root, announce);
self.update(
root,
json!({"sessionUpdate": "session_message", "messageId": "m1", "senderSessionId": root,
"recipientSessionId": "c1", "content": [{"type": "text", "text": "Find the lexer"}]}),
);
self.update("c1", json!({"sessionUpdate": "usage_update", "used": 900, "size": 1000}));
self.update(
"c1",
json!({"sessionUpdate": "tool_call", "toolCallId": "c1-t1", "title": "Read lexer.rs",
"kind": "read", "status": "completed"}),
);
let peer = self.peer.get().unwrap().clone();
let _: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": "c1", "toolCall": {"toolCallId": "c1-t2", "title": "Run ls"},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
None,
))
.unwrap();
self.say("c1", "It is in lexer.rs");
self.update(root, sub(json!({"state": "idle", "stopReason": "end_turn"})));
self.update(
root,
json!({"sessionUpdate": "session_message", "messageId": "m2", "senderSessionId": "c1",
"recipientSessionId": root, "content": [{"type": "text", "text": "lexer.rs"}]}),
);
self.say(root, "Found it");
}
}
impl Handler for FakeAgent {
fn on_notification(&self, method: &str, _: Value) {
self.calls.lock().push(method.to_owned());
}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}}))
}
"session/new" => Ok(json!({"sessionId": "s1",
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})),
"session/load" => {
self.say(&session, "replayed history");
Ok(json!({}))
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
Ok(json!({}))
}
"session/prompt" => {
let text = params.pointer("/prompt/0/text").and_then(Value::as_str).unwrap_or_default().to_owned();
if text == "again" {
self.say(&session, "second turn");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "delegate" {
self.delegate(&session);
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "wait" {
// Works until cancelled.
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
std::thread::sleep(Duration::from_millis(5));
}
return Ok(json!({"stopReason": "cancelled"}));
}
self.update(
&session,
json!({"sessionUpdate": "available_commands_update",
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
);
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
self.say(&session, "Hi");
self.update(
&session,
json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Edit a.rs",
"kind": "edit", "status": "completed",
"content": [{"type": "diff", "path": "/p/a.rs", "oldText": "a\n", "newText": "b\n"}]}),
);
let peer = self.peer.get().unwrap().clone();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("chose {chosen}"));
Ok(json!({"stopReason": "end_turn"}))
}
_ => Err((codes::METHOD_NOT_FOUND, method.to_owned())),
}
}
}
fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
manager_at(dir, &dir.join("models.json"), agent)
}
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
// Keep the latest connection: a restarted manager connects again.
let _ = agent.peer.set(server);
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
});
AgentManager::with_connector(
AgentPaths {
project_root: dir.to_path_buf(),
store_dir: dir.join("store"),
worktrees_dir: dir.join("worktrees"),
models_file: models_file.to_path_buf(),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let (a, b, models) = (tmp.path().join("a"), tmp.path().join("b"), tmp.path().join("models.json"));
std::fs::create_dir_all(a.join(".claude/skills/triage")).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n").unwrap();
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::NeedsInput).await;
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["review", "triage"]);
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.shutdown();
// Another project shares the settings but not the commands.
let mgr_b = manager_at(&b, &models, Arc::new(FakeAgent::default()));
let claude = mgr_b.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert_eq!(claude.config.len(), 2);
assert!(claude.commands.is_empty());
assert!(mgr_b.commands("claude", None).await.unwrap().is_empty());
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
if mgr.task(id).unwrap().state.lock().task.status == status {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("task never reached {status:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
let task = mgr
.create(NewAgentTask {
prompt: text("Fix the lexer\nmore detail"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
assert_eq!(task.title, "Fix the lexer");
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "a new task is not settled");
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let permission = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let kinds: Vec<String> = entries
.iter()
.map(|e| match e {
AgentEntry::User { text, .. } | AgentEntry::Agent { text } => text.clone(),
AgentEntry::Tool { title, .. } => title.clone(),
AgentEntry::Permission { chosen, .. } => format!("permission: {}", chosen.clone().unwrap_or_default()),
other => format!("{other:?}"),
})
.collect();
assert_eq!(kinds, ["Fix the lexer\nmore detail", "Hi", "Edit a.rs", "permission: Allow", "chose yes"]);
assert!(
agent.calls.lock().contains(&"session/set_config_option \"small\"".to_owned()),
"switched to the chosen model"
);
{
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
assert_eq!(t.model.as_deref(), Some("small"));
let modes = t.config.iter().find(|o| o.id == "mode").unwrap();
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
}
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
assert!(agent.calls.lock().contains(&"session/set_mode \"code\"".to_owned()));
assert!(mgr.set_config(&task.id, "mode", "nope".into()).await.is_err());
tokio::time::sleep(FLUSH_DELAY * 3).await;
{
let events = events.lock();
assert!(
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
);
let last_entry = events.iter().rev().find_map(|e| match e {
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
Some((*index, entry.clone()))
}
_ => None,
});
assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() })));
// The turn's end is recorded on the message that started it.
assert!(events.iter().any(|e| matches!(
e,
AgentEvent::Entry { index: 0, entry: AgentEntry::User { sent_ms: Some(_), done_ms: Some(_), .. }, .. }
)));
}
let providers = mgr.providers();
let claude = providers.iter().find(|p| p.id == "claude").unwrap();
assert_eq!(claude.config.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["model", "mode"]);
assert_eq!(claude.commands.len(), 1);
// A new manager (IDE restart) finds the task and resumes its session without duplicating history.
mgr.shutdown();
let agent2 = Arc::new(FakeAgent::default());
let mgr2 = manager(tmp.path(), agent2.clone());
assert_eq!(mgr2.entries(&task.id, None).unwrap().len(), 5);
assert_eq!(mgr2.task(&task.id).unwrap().state.lock().task.title, "Lexer fix");
assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2);
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
let entries = mgr2.entries(&task.id, None).unwrap();
assert_eq!(entries.len(), 7);
assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() });
assert!(agent2.calls.lock().contains(&"session/load".to_owned()));
// The chosen settings are re-applied to the reloaded session.
let calls = agent2.calls.lock().clone();
assert!(calls.contains(&"session/set_mode \"code\"".to_owned()), "{calls:?}");
}
fn text(t: &str) -> AgentPrompt {
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
}
#[tokio::test(flavor = "multi_thread")]
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Running).await;
mgr.prompt(&task.id, text("again"), false).await.unwrap();
let queue = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
assert_eq!(queue.iter().map(|q| q.prompt.text.as_str()).collect::<Vec<_>>(), ["again"]);
assert!(mgr.prompt(&task.id, text(" "), false).await.is_err());
mgr.send_queued_now(&task.id, &queue[0].id).unwrap();
for _ in 0..500 {
if mgr.entries(&task.id, None).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let users: Vec<String> = mgr
.entries(&task.id, None)
.unwrap()
.into_iter()
.filter_map(|e| match e {
AgentEntry::User { text, .. } => Some(text),
_ => None,
})
.collect();
assert_eq!(users, ["wait", "again"]);
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
// Stopping a turn keeps the queue for later.
mgr.prompt(&task.id, text("wait"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Running).await;
agent.calls.lock().retain(|c| c != "session/cancel");
mgr.prompt(&task.id, text("later"), false).await.unwrap();
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
assert_eq!(queued.len(), 1);
assert_eq!(mgr.unqueue(&task.id, &queued[0].id).unwrap().prompt.text, "later");
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn subagents_get_their_own_conversation() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
prompt: text("delegate"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
// The subagent's permission request is answered in the task's conversation.
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, title) = mgr
.entries(&task.id, None)
.unwrap()
.into_iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, .. } => Some((id, title)),
_ => None,
})
.unwrap();
assert_eq!(title, "Explore: Run ls");
mgr.respond(&task.id, id, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c == "subagents"));
let check = |mgr: &AgentManager| {
let main = mgr.entries(&task.id, None).unwrap();
let card = main.iter().find(|e| matches!(e, AgentEntry::Subagent { .. })).unwrap();
let AgentEntry::Subagent { title, status, prompt, reply, tools, activity, .. } = card else { unreachable!() };
assert_eq!(title.as_deref(), Some("Explore"));
assert_eq!(*status, AgentSubagentStatus::Done);
assert_eq!((prompt.as_deref(), reply.as_deref()), (Some("Find the lexer"), Some("lexer.rs")));
assert_eq!((*tools, activity.as_deref()), (1, Some("Read lexer.rs")));
assert!(main.contains(&AgentEntry::Agent { text: "Found it".into() }));
assert!(!main.iter().any(|e| matches!(e, AgentEntry::Tool { .. })));
let sub = mgr.entries(&task.id, Some("c1".into())).unwrap();
assert!(matches!(&sub[0], AgentEntry::Tool { title, .. } if title == "Read lexer.rs"));
assert_eq!(sub[1], AgentEntry::Agent { text: "It is in lexer.rs".into() });
};
// Updates can trail the end of the turn.
for _ in 0..500 {
if mgr.entries(&task.id, None).unwrap().contains(&AgentEntry::Agent { text: "Found it".into() }) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
check(&mgr);
// The subagent's context use is not the task's.
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.usage.as_ref().map(|u| u.used), Some(900));
assert!(mgr.entries(&task.id, Some("nope".into())).is_err());
// Saved with the task.
mgr.shutdown();
check(&manager(tmp.path(), Arc::new(FakeAgent::default())));
}
#[test]
fn prompts_become_acp_content_blocks() {
let p = AgentPrompt {
text: "look".into(),
files: vec![IpcPath(PathBuf::from("/p/my file.rs"))],
images: vec![AgentImage { mime_type: "image/png".into(), data: "AAA".into(), name: None }],
};
assert_eq!(
blocks(&p, &[]),
[
json!({"type": "text", "text": "look"}),
json!({"type": "resource_link", "uri": "file:///p/my%20file.rs", "name": "my file.rs"}),
json!({"type": "image", "mimeType": "image/png", "data": "AAA"}),
]
);
assert_eq!(
blocks(&p, &[Some("fn a() {}".into())])[1],
json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}})
);
// A slash command goes through as typed: the agent runs it.
let skill = clean(text(" /triage dry-run\n")).unwrap();
assert_eq!(blocks(&skill, &[]), [json!({"type": "text", "text": "/triage dry-run"})]);
let old = upgrade(
json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}),
);
assert_eq!(old["task"]["queue"], json!([]));
assert_eq!(old["entries"][0]["attachments"], json!([]));
assert!(old["entries"][1].get("attachments").is_none());
assert_eq!(title_of(&AgentPrompt { files: p.files.clone(), ..AgentPrompt::default() }), "my file.rs");
assert!(clean(text(" ")).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
prompt: text("do it"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")));
assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() }));
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default");
mgr.delete(&task.id, false).await.unwrap();
assert!(mgr.entries(&task.id, None).is_err());
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
assert!(
mgr.create(NewAgentTask {
prompt: text(" "),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None
})
.await
.is_err()
);
}
/// Drives the real Claude Code adapter (needs `npx`, network and a signed-in `claude`):
/// `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored --nocapture`.
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn real_claude_task_in_a_worktree() {
if std::env::var_os("OPENIDEA_AGENT_E2E").is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
for args in [&["init", "-q", "-b", "main"][..], &["commit", "-q", "--allow-empty", "-m", "init"]] {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&repo)
.status()
.unwrap()
.success();
assert!(ok);
}
let mgr = AgentManager::new(
AgentPaths {
project_root: repo.clone(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("wt"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
);
let task = mgr
.create(NewAgentTask {
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
isolation: Isolation::Worktree,
base: None,
})
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(240);
loop {
assert!(std::time::Instant::now() < deadline, "timed out");
let status = mgr.task(&task.id).unwrap().state.lock().task.status;
match status {
AgentStatus::NeedsInput => {
let pending = mgr.entries(&task.id, None).unwrap().into_iter().rev().find_map(|e| match e {
AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)),
_ => None,
});
if let Some((id, options)) = pending {
let allow = options.iter().find(|o| o.kind == "allow_once").or(options.first()).unwrap();
eprintln!("allowing: {}", allow.name);
let _ = mgr.respond(&task.id, id, Some(allow.id.clone()));
}
}
AgentStatus::Ready | AgentStatus::Failed => break,
_ => {}
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
for e in mgr.entries(&task.id, None).unwrap() {
eprintln!("{e:?}");
}
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
assert_eq!(t.status, AgentStatus::Ready, "{:?}", t.error);
assert_eq!(t.model.as_deref(), Some("haiku"));
let changes = mgr.changes(&task.id).await.unwrap();
assert!(changes.iter().any(|c| c.rel_path == "hello.txt"), "{changes:?}");
assert!(!repo.join("hello.txt").exists(), "the project checkout is untouched");
assert!(!mgr.providers().iter().find(|p| p.id == "claude").unwrap().config.is_empty());
mgr.shutdown();
}
+464
Переглянути файл
@@ -0,0 +1,464 @@
//! Folds ACP `session/update` notifications into a task's entries.
use crate::model::{AgentEntry, AgentPlanItem, AgentSubagentStatus, AgentToolDiff, AgentToolStatus};
use ide_core::IpcPath;
use serde_json::Value;
use std::collections::HashMap;
/// Tool output kept per call; longer output keeps its tail.
const MAX_OUTPUT: usize = 64 * 1024;
#[derive(Default)]
pub(crate) struct Timeline {
pub entries: Vec<AgentEntry>,
tools: HashMap<String, usize>,
plan: Option<usize>,
/// Subagent cards, by the subagent's session id.
cards: HashMap<String, usize>,
/// Session messages, by message id.
messages: HashMap<String, usize>,
}
/// What a `session/update` changed in the timeline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct Applied {
/// Indices of the entries added or changed.
pub changed: Vec<usize>,
}
impl Timeline {
pub fn from_entries(entries: Vec<AgentEntry>) -> Self {
let mut t = Self { entries, ..Self::default() };
for (i, e) in t.entries.iter().enumerate() {
match e {
AgentEntry::Tool { id, .. } => {
t.tools.insert(id.clone(), i);
}
AgentEntry::Plan { .. } => t.plan = Some(i),
AgentEntry::Subagent { id, .. } => {
t.cards.insert(id.clone(), i);
}
_ => {}
}
}
t
}
pub fn push(&mut self, entry: AgentEntry) -> usize {
self.entries.push(entry);
self.entries.len() - 1
}
/// Applies one `session/update` `update` object.
pub fn apply(&mut self, update: &Value) -> Applied {
let mut out = Applied::default();
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
match kind {
"agent_message_chunk" | "agent_thought_chunk" => {
let Some(text) = content_text(update.get("content")) else { return out };
out.changed.push(self.append_text(kind == "agent_thought_chunk", &text));
}
"tool_call" => {
let id = str_field(update, "toolCallId");
let entry = AgentEntry::Tool {
id: id.clone(),
title: str_field(update, "title"),
kind: update.get("kind").and_then(Value::as_str).unwrap_or("other").to_owned(),
status: tool_status(update.get("status")).unwrap_or(AgentToolStatus::Pending),
locations: locations(update.get("locations")),
output: None,
diffs: Vec::new(),
};
let index = match self.tools.get(&id) {
Some(&i) => {
self.entries[i] = entry;
i
}
None => {
let i = self.push(entry);
self.tools.insert(id, i);
i
}
};
self.merge_tool(index, update);
out.changed.push(index);
}
"tool_call_update" => {
let Some(&index) = self.tools.get(&str_field(update, "toolCallId")) else { return out };
self.merge_tool(index, update);
out.changed.push(index);
}
"plan" => {
let items = update
.get("entries")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|e| AgentPlanItem { content: str_field(e, "content"), status: str_field(e, "status") })
.collect()
})
.unwrap_or_default();
let entry = AgentEntry::Plan { items };
let index = match self.plan {
Some(i) => {
self.entries[i] = entry;
i
}
None => self.push(entry),
};
self.plan = Some(index);
out.changed.push(index);
}
_ => {}
}
out
}
fn append_text(&mut self, thought: bool, text: &str) -> usize {
match self.entries.last_mut() {
Some(AgentEntry::Agent { text: t }) if !thought => t.push_str(text),
Some(AgentEntry::Thought { text: t }) if thought => t.push_str(text),
_ => {
let text = text.to_owned();
return self.push(if thought { AgentEntry::Thought { text } } else { AgentEntry::Agent { text } });
}
}
self.entries.len() - 1
}
fn merge_tool(&mut self, index: usize, update: &Value) {
let AgentEntry::Tool { title, kind, status, locations: locs, output, diffs, .. } = &mut self.entries[index]
else {
return;
};
if let Some(t) = update.get("title").and_then(Value::as_str) {
*title = t.to_owned();
}
if let Some(k) = update.get("kind").and_then(Value::as_str) {
*kind = k.to_owned();
}
if let Some(s) = tool_status(update.get("status")) {
*status = s;
}
if update.get("locations").is_some_and(Value::is_array) {
*locs = locations(update.get("locations"));
}
let Some(content) = update.get("content").and_then(Value::as_array) else { return };
let mut text = String::new();
let mut new_diffs = Vec::new();
for item in content {
match item.get("type").and_then(Value::as_str) {
Some("content") => {
if let Some(t) = content_text(item.get("content")) {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&t);
}
}
Some("diff") => {
let old = item.get("oldText").and_then(Value::as_str).unwrap_or_default();
let new = item.get("newText").and_then(Value::as_str).unwrap_or_default();
let (added, removed) = line_stats(old, new);
new_diffs.push(AgentToolDiff { path: IpcPath(str_field(item, "path").into()), added, removed });
}
_ => {}
}
}
if !text.is_empty() {
*output = Some(tail(text));
}
if !new_diffs.is_empty() {
*diffs = new_diffs;
}
}
/// Adds or updates the card of subagent `id` from its `subagent_update`; returns the
/// card's index and whether the subagent just stopped working.
pub fn subagent(&mut self, id: &str, update: &Value, now: f64) -> (usize, bool) {
let index = match self.cards.get(id) {
Some(&i) => i,
None => {
let i = self.push(AgentEntry::Subagent {
id: id.to_owned(),
title: None,
description: None,
status: AgentSubagentStatus::Running,
error: None,
prompt: None,
reply: None,
tools: 0,
activity: None,
started_ms: now,
ended_ms: None,
cancellable: false,
});
self.cards.insert(id.to_owned(), i);
i
}
};
let AgentEntry::Subagent { title, description, status, error, ended_ms, cancellable, .. } =
&mut self.entries[index]
else {
return (index, false);
};
// Patch semantics: an omitted field is unchanged, `null` clears it.
if let Some(v) = update.get("title") {
*title = v.as_str().map(str::to_owned);
}
if let Some(v) = update.get("description") {
*description = v.as_str().map(str::to_owned);
}
if let Some(v) = update.get("capabilities") {
*cancellable = v.get("cancel").is_some_and(Value::is_object);
}
let mut stopped = false;
if let Some((next, message)) = update.get("state").and_then(subagent_status) {
let working = matches!(next, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput);
stopped = !working && ended_ms.is_none();
*ended_ms = if working { None } else { ended_ms.or(Some(now)) };
*status = next;
*error = message;
}
(index, stopped)
}
/// Sets (or with `append`, extends) what subagent `id` was asked or answered, on its card.
pub fn card_message(&mut self, id: &str, reply: bool, text: &str, append: bool) -> Option<usize> {
let &index = self.cards.get(id)?;
let AgentEntry::Subagent { prompt, reply: answer, .. } = &mut self.entries[index] else { return None };
let field = if reply { answer } else { prompt };
match field {
Some(t) if append => t.push_str(text),
_ => *field = Some(text.to_owned()),
}
Some(index)
}
/// Adds, replaces or (with `append`) extends session message `id`.
pub fn message(&mut self, id: &str, incoming: bool, text: &str, append: bool) -> usize {
if let Some(&i) = self.messages.get(id)
&& let AgentEntry::Message { text: t, .. } = &mut self.entries[i]
{
if append {
t.push_str(text);
} else {
*t = text.to_owned();
}
return i;
}
let i = self.push(AgentEntry::Message { incoming, text: text.to_owned() });
self.messages.insert(id.to_owned(), i);
i
}
/// Updates subagent `id`'s card with what its own timeline `of` shows: how many tool
/// calls it made and its latest one. Returns the card's index when it changed.
pub fn card_activity(&mut self, id: &str, of: &Timeline) -> Option<usize> {
let &index = self.cards.get(id)?;
let AgentEntry::Subagent { tools, activity, .. } = &mut self.entries[index] else { return None };
let calls = of.entries.iter().filter_map(|e| match e {
AgentEntry::Tool { title, kind, .. } => Some(if title.is_empty() { kind } else { title }),
_ => None,
});
let count = calls.clone().count() as u32;
let latest = calls.clone().next_back().cloned();
if *tools == count && *activity == latest {
return None;
}
*tools = count;
*activity = latest;
Some(index)
}
/// Marks the subagents still working as stopped (their agent is gone).
pub fn stop_subagents(&mut self, now: f64) -> Vec<usize> {
let mut changed = Vec::new();
for (i, e) in self.entries.iter_mut().enumerate() {
if let AgentEntry::Subagent { status, ended_ms, .. } = e
&& matches!(status, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput)
{
*status = AgentSubagentStatus::Cancelled;
*ended_ms = Some(now);
changed.push(i);
}
}
changed
}
/// Settles what an ended turn left open: running tools and pending permissions,
/// and records when the turn ended (`done`) on the message that started it.
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
let mut changed = Vec::new();
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
for (i, e) in self.entries.iter_mut().enumerate() {
match e {
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
*done_ms = done;
changed.push(i);
}
AgentEntry::Tool { status, .. }
if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) =>
{
*status = AgentToolStatus::Failed;
changed.push(i);
}
AgentEntry::Permission { chosen, .. } if chosen.is_none() => {
*chosen = Some("Cancelled".into());
changed.push(i);
}
_ => {}
}
}
changed
}
}
fn str_field(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned()
}
/// The status a subagent `state` reports, with its error message; `None` for an unknown state.
fn subagent_status(state: &Value) -> Option<(AgentSubagentStatus, Option<String>)> {
Some(match state.get("state")?.as_str()? {
"running" => (AgentSubagentStatus::Running, None),
"requires_action" => (AgentSubagentStatus::NeedsInput, None),
"idle" => match state.get("stopReason").and_then(Value::as_str) {
Some("cancelled") => (AgentSubagentStatus::Cancelled, None),
Some("error") => (
AgentSubagentStatus::Failed,
state.pointer("/error/message").and_then(Value::as_str).map(str::to_owned),
),
_ => (AgentSubagentStatus::Done, None),
},
_ => return None,
})
}
/// The text of a list of content blocks, one per line.
pub(crate) fn blocks_text(content: Option<&Value>) -> String {
let blocks = content.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
blocks.iter().filter_map(|b| content_text(Some(b))).collect::<Vec<_>>().join("\n")
}
pub(crate) fn content_text(content: Option<&Value>) -> Option<String> {
let c = content?;
match c.get("type").and_then(Value::as_str) {
Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned),
Some("resource_link") => c.get("uri").and_then(Value::as_str).map(str::to_owned),
Some("resource") => c.pointer("/resource/text").and_then(Value::as_str).map(str::to_owned),
_ => None,
}
}
fn tool_status(v: Option<&Value>) -> Option<AgentToolStatus> {
Some(match v?.as_str()? {
"pending" => AgentToolStatus::Pending,
"in_progress" => AgentToolStatus::Running,
"completed" => AgentToolStatus::Completed,
"failed" => AgentToolStatus::Failed,
_ => return None,
})
}
fn locations(v: Option<&Value>) -> Vec<IpcPath> {
v.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|l| l.get("path")?.as_str()).map(|p| IpcPath(p.into())).collect())
.unwrap_or_default()
}
fn line_stats(old: &str, new: &str) -> (u32, u32) {
ide_vcs::diff_hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.lines, r + h.old_lines))
}
fn tail(mut text: String) -> String {
if text.len() > MAX_OUTPUT {
let mut cut = text.len() - MAX_OUTPUT;
while !text.is_char_boundary(cut) {
cut += 1;
}
text.replace_range(..cut, "…");
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn chunks_merge_into_one_message_until_another_entry_comes() {
let mut t = Timeline::default();
let chunk = |s: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": s}});
assert_eq!(t.apply(&chunk("Hel")).changed, vec![0]);
assert_eq!(t.apply(&chunk("lo")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}}));
t.apply(&chunk("again"));
assert_eq!(
t.entries,
vec![
AgentEntry::Agent { text: "Hello".into() },
AgentEntry::Thought { text: "hmm".into() },
AgentEntry::Agent { text: "again".into() },
]
);
}
#[test]
fn tool_calls_update_in_place() {
let mut t = Timeline::default();
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Edit a.rs", "kind": "edit",
"status": "pending", "locations": [{"path": "/p/a.rs", "line": 3}]}));
let applied = t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed",
"content": [
{"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\n", "newText": "a\nB\nc\n"},
{"type": "content", "content": {"type": "text", "text": "done"}}
]}));
assert_eq!(applied.changed, vec![0]);
let AgentEntry::Tool { status, diffs, output, locations, .. } = &t.entries[0] else { panic!() };
assert_eq!(*status, AgentToolStatus::Completed);
assert_eq!(diffs, &vec![AgentToolDiff { path: IpcPath("/p/a.rs".into()), added: 2, removed: 1 }]);
assert_eq!(output.as_deref(), Some("done"));
assert_eq!(locations, &vec![IpcPath("/p/a.rs".into())]);
// Unknown calls are ignored.
assert!(t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "zz"})).changed.is_empty());
}
#[test]
fn plan_is_one_entry_and_end_turn_settles_open_items() {
let mut t = Timeline::default();
let plan =
|s: &str| json!({"sessionUpdate": "plan", "entries": [{"content": "x", "priority": "high", "status": s}]});
t.apply(&plan("pending"));
t.push(AgentEntry::Agent { text: "between".into() });
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
assert_eq!(t.end_turn(None), vec![2, 3]);
let again = Timeline::from_entries(t.entries.clone());
assert_eq!(again.plan, Some(0));
assert_eq!(again.tools.get("c"), Some(&2));
}
#[test]
fn end_turn_records_when_the_last_message_was_answered() {
let user =
|sent: f64| AgentEntry::User { text: "q".into(), attachments: vec![], sent_ms: Some(sent), done_ms: None };
let mut t = Timeline::default();
t.push(user(1.0));
t.push(AgentEntry::Agent { text: "a".into() });
t.push(user(2.0));
assert_eq!(t.end_turn(Some(5.0)), vec![2]);
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: None, .. }));
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: Some(5.0), .. }));
// Settled once: a later end (a restart) doesn't move it.
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
assert!(s.starts_with('…') && s.len() <= MAX_OUTPUT + 3);
}
}
+188
Переглянути файл
@@ -0,0 +1,188 @@
//! Task worktrees and the changes made in them.
use crate::model::AgentChange;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, Repo};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
pub(crate) struct Created {
pub path: PathBuf,
pub branch: String,
pub base: String,
pub base_commit: String,
}
fn repo_at(path: &Path) -> IdeResult<Repo> {
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
}
/// The current branch (or commit when detached) and its commit.
pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
let info = repo_at(path)?.branch_info()?;
let commit = info.head_commit.ok_or_else(|| IdeError::unavailable("the repository has no commits yet"))?;
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
}
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
let repo = repo_at(project_root)?;
let (base, base_commit) = match base.map(str::trim).filter(|b| !b.is_empty()) {
Some(b) => {
let commit =
repo.merge_base(b, b)?.ok_or_else(|| IdeError::not_found(format!("no branch or revision {b}")))?;
(b.to_owned(), commit)
}
None => head_of(project_root)?,
};
std::fs::create_dir_all(worktrees_dir).map_err(|e| IdeError::internal(e.to_string()))?;
let branch_exists = |s: &str| {
let r = format!("refs/heads/agent/{s}");
repo.merge_base(&r, &r).ok().flatten().is_some()
};
let slug = unique_slug(&slugify(title), |s| worktrees_dir.join(s).exists() || branch_exists(s));
let path = worktrees_dir.join(&slug);
let branch = format!("agent/{slug}");
repo.add_worktree(&path, &base_commit, Some(&branch))?;
Ok(Created { path, branch, base, base_commit })
}
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
repo_at(project_root)?.remove_worktree(path, true)
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let repo = repo_at(cwd)?;
let root = repo.root().to_path_buf();
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
let mut out: Vec<AgentChange> = repo
.diff_with_worktree(base)?
.into_iter()
.map(|f| {
let (added, removed) = stat(&f.path);
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
})
.collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
/// Size and modification time of a file, `None` once it is gone.
pub(crate) type Stamp = Option<(u64, u128)>;
fn stamp(path: &Path) -> Stamp {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
Some((meta.len(), modified))
}
/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show.
pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect())
}
/// Drops the changes that were there before the task and haven't been touched since.
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
repo_at(cwd)?.file_at(path, Some(rev))
}
/// A short ASCII branch-safe name from the task title.
pub(crate) fn slugify(title: &str) -> String {
let mut out = String::new();
for c in title.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
if out.len() >= 32 {
break;
}
}
let out = out.trim_matches('-');
if out.is_empty() { "task".to_owned() } else { out.to_owned() }
}
fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_owned();
}
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("unbounded")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs_are_branch_safe() {
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
assert_eq!(slugify("修复 lexer 越界"), "lexer");
assert_eq!(slugify("修复越界"), "task");
assert_eq!(slugify(&"a".repeat(50)).len(), 32);
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
}
fn git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?}");
}
#[test]
fn worktree_changes_are_listed_against_the_base() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
assert_eq!(created.branch, "agent/edit-a");
assert_eq!(created.base, "main");
std::fs::write(created.path.join("a.txt"), "one\nTWO\nthree\n").unwrap();
std::fs::write(created.path.join("new.txt"), "x\ny\n").unwrap();
let got = changes(&created.path, &created.base_commit).unwrap();
let summary: Vec<_> = got.iter().map(|c| (c.rel_path.as_str(), c.kind, c.added, c.removed)).collect();
assert_eq!(summary, [("a.txt", ChangeKind::Modified, 2, 1), ("new.txt", ChangeKind::Added, 2, 0)]);
let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap();
assert_eq!(base_text.as_deref(), Some("one\ntwo\n"));
// Changes already in the checkout drop out until they are edited again.
let before = snapshot(&created.path).unwrap();
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
let rels = |c: Vec<AgentChange>| c.into_iter().map(|c| c.rel_path).collect::<Vec<_>>();
let after = changes(&created.path, "HEAD").unwrap();
assert_eq!(rels(without_untouched(after, &before)), ["b.txt"]);
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(created.path.join("new.txt"), "x\ny\nz\n").unwrap();
let after = changes(&created.path, "HEAD").unwrap();
assert_eq!(rels(without_untouched(after, &before)), ["b.txt", "new.txt"]);
// A second task with the same title gets its own worktree.
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
remove(&repo, &created.path).unwrap();
assert!(!created.path.exists());
}
}
+1 -1
Переглянути файл
@@ -19,7 +19,7 @@ fn main() {
eprintln!("synced in {:?}: {r:?}", started.elapsed());
}
let started = std::time::Instant::now();
let model = ide_build::load(&root, &cache);
let model = ide_build::load(&root, &cache, None);
println!("{}", serde_json::to_string_pretty(&model).unwrap());
eprintln!("loaded in {:?}", started.elapsed());
}
+4 -2
Переглянути файл
@@ -225,6 +225,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -583,6 +584,7 @@ pub fn diagnostics(messages: &[BuildMessage]) -> BTreeMap<PathBuf, Vec<Diagnosti
code: m.code.clone(),
unnecessary: false,
deprecated: false,
server: Some(DIAGNOSTICS_SOURCE.to_owned()),
});
}
out
@@ -671,7 +673,7 @@ mod tests {
);
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&root, "core/src/main/java/A.java", "class A {}");
let model = crate::load(&root, &tmp.path().join("cache"));
let model = crate::load(&root, &tmp.path().join("cache"), None);
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
@@ -685,7 +687,7 @@ mod tests {
let g = tmp.path().join("g");
write(&g, "settings.gradle", "include 'app'");
write(&g, "app/build.gradle", "apply plugin: 'java'");
let model = crate::load(&g, &tmp.path().join("cache2"));
let model = crate::load(&g, &tmp.path().join("cache2"), None);
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
assert_eq!(p.system, BuildSystem::Gradle);
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
+100
Переглянути файл
@@ -285,6 +285,74 @@ pub fn script_java_version(text: &str) -> Option<u32> {
None
}
/// Configurations whose dependencies are libraries (`testFixturesImplementation` ends in one).
const DEPENDENCY_CONFIGURATIONS: &[&str] = &[
"implementation",
"api",
"compileOnly",
"runtimeOnly",
"compileOnlyApi",
"annotationProcessor",
"kapt",
"ksp",
"compile",
"runtime",
"testCompile",
"testRuntime",
"developmentOnly",
];
fn is_dependency_configuration(name: &str) -> bool {
DEPENDENCY_CONFIGURATIONS.iter().any(|c| {
name == *c || {
let mut cap = c.chars();
let first = cap.next().map(|f| f.to_ascii_uppercase()).into_iter();
let capitalised: String = first.chain(cap).collect();
name.len() > c.len() && name.ends_with(&capitalised)
}
})
}
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
continue;
}
let rest = &line[name.len()..];
let lits = literals(rest);
let map_value = |key: &str| -> Option<String> {
let i = rest.find(key)?;
let after = rest[i + key.len()..].trim_start().strip_prefix([':', '='])?;
literals(after).into_iter().next()
};
let coordinate = match (map_value("group"), map_value("name"), map_value("version")) {
(Some(g), Some(a), Some(v)) => Some((g, a, v)),
_ => lits.first().and_then(|l| {
let l = l.split('@').next().unwrap_or(l);
let mut parts = l.split(':');
Some((parts.next()?.to_owned(), parts.next()?.to_owned(), parts.next()?.to_owned()))
}),
};
if let Some((g, a, v)) = coordinate {
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
if plain(&g) && plain(&a) && plain(&v) && !v.contains('+') {
out.push((g, a, v));
}
}
}
out
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
@@ -333,6 +401,38 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
#[cfg(test)]
mod tests {
#[test]
fn script_dependencies_read_string_and_map_notations() {
let script = r#"
plugins { id 'java' }
dependencies {
implementation 'com.google.guava:guava:33.0.0-jre'
api("org.slf4j:slf4j-api:2.0.9")
testImplementation "junit:junit:4.13.2@jar"
// implementation 'commented:out:1.0'
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'
runtimeOnly(group = "com.h2database", name = "h2", version = "2.2.224")
implementation "org.x:dyn:${ver}"
implementation 'org.x:range:1.+'
implementation libs.okhttp
implementation project(':core')
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
[
"com.google.guava:guava:33.0.0-jre",
"org.slf4j:slf4j-api:2.0.9",
"junit:junit:4.13.2",
"org.projectlombok:lombok:1.18.30",
"com.h2database:h2:2.2.224",
]
);
}
use super::*;
#[test]
+113 -6
Переглянути файл
@@ -8,17 +8,18 @@
pub mod compile;
pub mod gradle;
pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -36,9 +37,10 @@ pub struct BuildModel {
}
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
pub fn load(root: &Path, cache_dir: &Path) -> BuildModel {
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load(root);
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
@@ -49,6 +51,60 @@ pub fn load(root: &Path, cache_dir: &Path) -> BuildModel {
BuildModel { maven, gradle }
}
/// A library the project depends on, as `groupId:artifactId:version`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyCoordinate {
/// Gradle builds resolve into the Gradle cache, Maven ones into the local repository.
pub gradle: bool,
pub group_id: String,
pub artifact_id: String,
pub version: String,
}
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
let modules: std::collections::HashSet<String> = m.projects.iter().map(MavenProject::key).collect();
let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect();
while let Some(d) = stack.pop() {
// Omitted occurrences (conflict losers, duplicates) are not on any classpath.
if !d.resolved() {
continue;
}
stack.extend(&d.children);
let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else {
continue;
};
if d.kind == "pom" || matches!(d.scope.as_str(), "import" | "system") {
continue;
}
if modules.contains(&format!("{}:{}", d.group_id, d.artifact_id)) {
continue;
}
out.push(DependencyCoordinate {
gradle: false,
group_id: d.group_id.clone(),
artifact_id: d.artifact_id.clone(),
version: version.to_owned(),
});
}
}
if let Some(g) = &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
out.dedup();
out
}
/// Whether a created / changed / removed project-relative path can change the build model.
pub fn affects_build(rel: &Path) -> bool {
let Some(name) = rel.file_name().and_then(|n| n.to_str()) else { return false };
@@ -68,13 +124,64 @@ mod tests {
let cache = tmp.path().join("cache");
let root = tmp.path().join("p");
std::fs::create_dir_all(root.join("svc")).unwrap();
assert_eq!(load(&root, &cache), BuildModel::default());
assert_eq!(load(&root, &cache, None), BuildModel::default());
std::fs::write(root.join("svc/pom.xml"), "<project><artifactId>svc</artifactId></project>").unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
let m = load(&root, &cache);
let m = load(&root, &cache, None);
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
assert!(!affects_build(Path::new("src/Main.java")));
}
#[test]
fn dependency_coordinates_flatten_maven_trees_and_read_gradle_scripts() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "dependencies { implementation 'org.a:x:1.0' }\n").unwrap();
let dep = |g: &str, a: &str, v: Option<&str>, children: Vec<MavenDependency>| MavenDependency {
group_id: g.into(),
artifact_id: a.into(),
version: v.map(Into::into),
kind: "jar".into(),
scope: "compile".into(),
children,
..Default::default()
};
let maven = MavenModel {
projects: vec![
MavenProject {
group_id: "me".into(),
artifact_id: "app".into(),
dependencies: vec![
dep("me", "core", Some("1"), vec![]),
dep(
"org.b",
"y",
Some("2"),
vec![
dep("org.c", "z", Some("3"), vec![]),
MavenDependency {
omitted: Some(Omission::Conflict),
conflict_with: Some("3".into()),
..dep("org.c", "z", Some("1"), vec![])
},
],
),
dep("org.d", "unresolved", Some("${v}"), vec![]),
],
..Default::default()
},
MavenProject { group_id: "me".into(), artifact_id: "core".into(), ..Default::default() },
],
..Default::default()
};
let gradle = gradle::load(root);
let model = BuildModel { maven: Some(maven), gradle };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}{}:{}:{}", if c.gradle { "G " } else { "M " }, c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
}
+145 -11
Переглянути файл
@@ -92,6 +92,8 @@ pub struct MavenProject {
pub java_version: Option<u32>,
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
pub command: String,
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
pub options: Vec<String>,
/// Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell.
pub env: BTreeMap<String, String>,
}
@@ -128,6 +130,37 @@ pub struct MavenDependency {
pub optional: bool,
/// Transitive dependencies (only after a sync).
pub children: Vec<MavenDependency>,
/// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`).
#[serde(default)]
#[specta(optional)]
pub omitted: Option<Omission>,
/// The version that won when `omitted` is `Conflict`.
#[serde(default)]
#[specta(optional)]
pub conflict_with: Option<String>,
/// The version requested before `<dependencyManagement>` replaced it.
#[serde(default)]
#[specta(optional)]
pub managed_from: Option<String>,
}
impl MavenDependency {
/// Part of the resolved graph (not a verbose-tree placeholder for an omitted occurrence).
pub fn resolved(&self) -> bool {
self.omitted.is_none()
}
}
/// Why a dependency occurrence is not part of the resolved graph.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Omission {
/// Another occurrence of the same version is nearer the root.
Duplicate,
/// Another version is nearer the root (or declared first) and wins.
Conflict,
/// The artifact depends on itself through this path.
Cycle,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -410,7 +443,7 @@ struct Resolver {
}
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
fn default_local_repo() -> Option<PathBuf> {
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
@@ -869,7 +902,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
classifier: d.classifier.clone(),
scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
@@ -924,6 +957,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
options: Vec::new(),
env: BTreeMap::new(),
});
for m in modules.into_iter().rev() {
@@ -965,7 +999,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
classifier: d.classifier.clone(),
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
(format!("{group}:{}", raw.artifact_id), plugins, deps)
@@ -973,13 +1007,16 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
.collect()
}
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line.
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line, with the
/// `-Dverbose` annotations: `(g:a:jar:1:compile - omitted for conflict with 2)`,
/// `g:a:jar:1:compile (version managed from 0.9)`, `(optional)`.
fn parse_coords(s: &str) -> Option<MavenDependency> {
let s = s.trim();
let (coords, extra) = match s.find(" (") {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
let (s, wrapped) = match s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
Some(inner) => (inner, true),
None => (s, false),
};
let (coords, notes) = s.split_once(' ').unwrap_or((s, ""));
let parts: Vec<&str> = coords.split(':').collect();
let (g, a, t, c, v, scope) = match parts.as_slice() {
[g, a, t, v] => (g, a, t, None, v, None),
@@ -987,16 +1024,35 @@ fn parse_coords(s: &str) -> Option<MavenDependency> {
[g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)),
_ => return None,
};
Some(MavenDependency {
let mut dep = MavenDependency {
group_id: (*g).to_owned(),
artifact_id: (*a).to_owned(),
version: Some((*v).to_owned()),
kind: (*t).to_owned(),
classifier: c.map(|c| (*c).to_owned()),
scope: scope.unwrap_or("compile").to_owned(),
optional: extra.contains("optional"),
children: Vec::new(),
})
..MavenDependency::default()
};
let notes = notes.replace(['(', ')'], ";").replace(" - ", ";");
for note in notes.split(';').map(|n| n.trim().trim_start_matches("- ").trim()) {
if note == "optional" {
dep.optional = true;
} else if let Some(winner) = note.strip_prefix("omitted for conflict with ") {
dep.omitted = Some(Omission::Conflict);
dep.conflict_with = Some(winner.trim().to_owned());
} else if note == "omitted for duplicate" {
dep.omitted = Some(Omission::Duplicate);
} else if note == "omitted for cycle" {
dep.omitted = Some(Omission::Cycle);
} else if let Some(from) = note.strip_prefix("version managed from ") {
dep.managed_from = Some(from.trim().to_owned());
}
}
// A parenthesised line is always an omitted occurrence, even with an unknown reason.
if wrapped && dep.omitted.is_none() {
dep.omitted = Some(Omission::Duplicate);
}
Some(dep)
}
fn insert_at(list: &mut Vec<MavenDependency>, depth: usize, dep: MavenDependency) {
@@ -1324,4 +1380,82 @@ mod tests {
);
assert_eq!(trees[2].1[0].scope, "compile");
}
/// `mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree -Dverbose` on a project
/// whose libraries ask for different commons-lang3 / commons-text / guava versions.
#[test]
fn verbose_dependency_tree_output() {
let trees = parse_dependency_tree(include_str!("../testdata/dependency-tree-verbose.txt"));
assert_eq!(trees.len(), 1);
let (key, deps) = &trees[0];
assert_eq!(key, "demo:conflict-demo");
let direct: Vec<&str> = deps.iter().map(|d| d.artifact_id.as_str()).collect();
assert_eq!(direct, ["hamcrest-core", "commons-text", "commons-configuration2", "guava", "guice", "junit"]);
assert!(deps.iter().all(MavenDependency::resolved));
let config = &deps[2].children;
let lang3 = &config[0];
assert_eq!(
(lang3.artifact_id.as_str(), lang3.version.as_deref(), lang3.omitted, lang3.conflict_with.as_deref()),
("commons-lang3", Some("3.14.0"), Some(Omission::Conflict), Some("3.12.0"))
);
assert_eq!(lang3.scope, "compile");
let logging = &config[2];
assert_eq!(
(logging.version.as_deref(), logging.managed_from.as_deref(), logging.omitted),
(Some("1.2"), Some("1.3.0"), None)
);
let guava = &deps[4].children[2];
assert_eq!((guava.version.as_deref(), guava.conflict_with.as_deref()), (Some("30.1-jre"), Some("32.1.3-jre")));
let hamcrest = &deps[5].children[0];
assert_eq!((hamcrest.omitted, hamcrest.scope.as_str()), (Some(Omission::Duplicate), "test"));
assert_eq!(deps[1].children[0].omitted, None, "the winning commons-lang3");
}
#[test]
fn verbose_annotations() {
// (line, omitted, conflict winner, managed from, optional)
type Case<'a> = (&'a str, Option<Omission>, Option<&'a str>, Option<&'a str>, bool);
let cases: &[Case] = &[
("(g:a:jar:1:compile - omitted for cycle)", Some(Omission::Cycle), None, None, false),
(
"(g:a:jar:1:compile - version managed from 0.9; omitted for duplicate)",
Some(Omission::Duplicate),
None,
Some("0.9"),
false,
),
("g:a:jar:1:compile (optional)", None, None, None, true),
(
"(g:a:jar:1:runtime (optional) - omitted for conflict with 2)",
Some(Omission::Conflict),
Some("2"),
None,
true,
),
(
"(g:a:jar:1:compile - scope updated from runtime; omitted for duplicate)",
Some(Omission::Duplicate),
None,
None,
false,
),
(
"g:a:jar:1:compile (version managed from 0.9; scope managed from runtime)",
None,
None,
Some("0.9"),
false,
),
];
for (line, omitted, winner, managed, optional) in cases {
let d = parse_coords(line).unwrap();
assert_eq!(
(d.omitted, d.conflict_with.as_deref(), d.managed_from.as_deref(), d.optional),
(*omitted, *winner, *managed, *optional),
"{line}"
);
assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}");
}
}
}
+336
Переглянути файл
@@ -0,0 +1,336 @@
//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct
//! dependency is declared and adding an `<exclusion>` to it, in the style
//! (indentation, line endings, one-line elements) of the surrounding POM.
use crate::maven::{self, MavenModel};
use crate::xml::{self, Element};
use ide_core::{Pos, Range, TextEdit};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{HashMap, HashSet};
/// `groupId:artifactId` of a dependency.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactKey {
pub group_id: String,
pub artifact_id: String,
}
impl ArtifactKey {
pub fn new(group_id: impl Into<String>, artifact_id: impl Into<String>) -> Self {
Self { group_id: group_id.into(), artifact_id: artifact_id.into() }
}
}
impl std::fmt::Display for ArtifactKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.group_id, self.artifact_id)
}
}
/// What adding an exclusion does to a POM.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Exclusion {
/// The edit that adds it.
Edit(TextEdit),
/// The dependency already excludes it (or everything, `*:*`).
Present,
}
/// Properties the coordinates of a POM's dependencies may refer to.
fn properties(project: &Element) -> HashMap<String, String> {
let mut props: HashMap<String, String> = project
.child("properties")
.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect())
.unwrap_or_default();
let own_or_parent = |name: &str| project.text_of(name).or_else(|| project.child("parent")?.text_of(name));
for (key, value) in [
("project.groupId", own_or_parent("groupId")),
("project.version", own_or_parent("version")),
("project.artifactId", project.text_of("artifactId")),
] {
if let Some(v) = value {
props.insert(key.into(), v);
}
}
props
}
fn matches(e: &Element, key: &ArtifactKey, props: &HashMap<String, String>) -> bool {
let text = |name: &str| e.text_of(name).map(|t| maven::interpolate(&t, props));
text("groupId").as_deref() == Some(key.group_id.as_str())
&& text("artifactId").as_deref() == Some(key.artifact_id.as_str())
}
/// The `<dependency>` declaring `key` in the project's `<dependencies>`, else in a
/// profile's (never `<dependencyManagement>`, which declares no dependency).
fn find_dependency<'a>(project: &'a Element, key: &ArtifactKey) -> Option<&'a Element> {
let props = properties(project);
let own = project.list("dependencies", "dependency");
let profiles = project.list("profiles", "profile").into_iter().flat_map(|p| p.list("dependencies", "dependency"));
own.into_iter().chain(profiles).find(|d| matches(d, key, &props))
}
/// 0-based line / UTF-16 column of byte offset `at`.
fn pos_at(text: &str, at: usize) -> Pos {
let before = &text[..at];
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32)
}
fn line_start(text: &str, at: usize) -> usize {
text[..at].rfind('\n').map_or(0, |i| i + 1)
}
/// The whitespace before `at` on its line, or `None` when other text precedes it there.
fn indent_before(text: &str, at: usize) -> Option<&str> {
let ws = &text[line_start(text, at)..at];
ws.chars().all(|c| c == ' ' || c == '\t').then_some(ws)
}
/// One level of indentation: what the first child of `e` adds to `e`'s, else two spaces.
fn indent_unit(text: &str, e: &Element) -> String {
let outer = indent_before(text, e.start);
let inner = e.children.first().and_then(|c| indent_before(text, c.start));
match (outer, inner) {
(Some(o), Some(i)) if i.len() > o.len() && i.starts_with(o) => i[o.len()..].to_owned(),
(_, Some(i)) if i.starts_with('\t') => "\t".into(),
_ => " ".into(),
}
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;")
}
/// Range of the `<artifactId>` of the dependency declaring `key` in `text` (a POM), or of
/// the whole `<dependency>` when it has none; `None` when the POM does not declare it.
pub fn declaration(text: &str, key: &ArtifactKey) -> Option<Range> {
let project = xml::parse(text)?;
let dep = find_dependency(&project, key)?;
let target = dep.child("artifactId").unwrap_or(dep);
Some(Range::new(pos_at(text, target.start), pos_at(text, target.end)))
}
/// Adds `exclude` to the `<exclusions>` of the dependency declaring `dependency` in `text`
/// (a POM); `None` when the POM does not declare it.
pub fn add_exclusion(text: &str, dependency: &ArtifactKey, exclude: &ArtifactKey) -> Option<Exclusion> {
let project = xml::parse(text)?;
let dep = find_dependency(&project, dependency)?;
let nl = if text.contains("\r\n") { "\r\n" } else { "\n" };
let unit = indent_unit(text, dep);
let (g, a) = (escape(&exclude.group_id), escape(&exclude.artifact_id));
// Insert `block` (lines relative to `parent`'s indentation + one level) before `parent`'s
// end tag; on one line (`compact`) when `parent` is not laid out one element per line.
let insert = |parent: &Element, lines: &[(usize, String)], compact: String| {
let close_on_own_line = indent_before(text, parent.inner_end).is_some() && parent.inner_end < parent.end;
let (at, new_text) = match indent_before(text, parent.start) {
Some(base) if close_on_own_line => {
let mut out = String::new();
for (depth, line) in lines {
out.push_str(base);
out.push_str(&unit.repeat(depth + 1));
out.push_str(line);
out.push_str(nl);
}
(line_start(text, parent.inner_end), out)
}
_ => (parent.inner_end, compact),
};
let p = pos_at(text, at);
Exclusion::Edit(TextEdit { range: Range::point(p), new_text })
};
let exclusion_lines = |depth: usize| {
vec![
(depth, "<exclusion>".to_owned()),
(depth + 1, format!("<groupId>{g}</groupId>")),
(depth + 1, format!("<artifactId>{a}</artifactId>")),
(depth, "</exclusion>".to_owned()),
]
};
let compact = format!("<exclusion><groupId>{g}</groupId><artifactId>{a}</artifactId></exclusion>");
if let Some(exclusions) = dep.child("exclusions") {
let props = properties(&project);
let wildcard = ArtifactKey::new("*", "*");
let any_artifact = ArtifactKey::new(exclude.group_id.clone(), "*");
if exclusions
.children_named("exclusion")
.any(|e| [exclude, &wildcard, &any_artifact].iter().any(|k| matches(e, k, &props)))
{
return Some(Exclusion::Present);
}
return Some(insert(exclusions, &exclusion_lines(0), compact));
}
let mut lines = vec![(0, "<exclusions>".to_owned())];
lines.extend(exclusion_lines(1));
lines.push((0, "</exclusions>".to_owned()));
Some(insert(dep, &lines, format!("<exclusions>{compact}</exclusions>")))
}
/// The POMs that may declare a dependency of the module `pom` (project-relative), nearest
/// first: the module's own, then its parents that are part of the project.
pub fn declaring_poms(model: &MavenModel, pom: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut current = model.projects.iter().find(|p| p.pom == pom);
while let Some(p) = current {
if !seen.insert(p.pom.clone()) {
break;
}
out.push(p.pom.clone());
current = p.parent.as_deref().and_then(|coords| {
model.projects.iter().find(|q| format!("{}:{}:{}", q.group_id, q.artifact_id, q.version) == coords)
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(text: &str, edit: &TextEdit) -> String {
let offset = |p: Pos| {
let start: usize = text.split_inclusive('\n').take(p.line as usize).map(str::len).sum();
let line = &text[start..];
start + line.char_indices().nth(p.col as usize).map_or(line.len(), |(i, _)| i)
};
let (from, to) = (offset(edit.range.start), offset(edit.range.end));
format!("{}{}{}", &text[..from], edit.new_text, &text[to..])
}
fn exclude(text: &str, dep: (&str, &str), excl: (&str, &str)) -> Option<String> {
match add_exclusion(text, &ArtifactKey::new(dep.0, dep.1), &ArtifactKey::new(excl.0, excl.1))? {
Exclusion::Edit(e) => Some(apply(text, &e)),
Exclusion::Present => Some(text.to_owned()),
}
}
const POM: &str = "<project>
<groupId>demo</groupId>
<properties>
<guice.group>com.google.inject</guice.group>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>${guice.group}</groupId>
<artifactId>guice</artifactId>
</dependency>
</dependencies>
</project>
";
#[test]
fn adds_exclusions_to_the_declaration_not_the_managed_entry() {
let out = exclude(POM, ("com.google.inject", "guice"), ("com.google.guava", "guava")).unwrap();
let expected = POM.replace(
" <artifactId>guice</artifactId>\n </dependency>\n </dependencies>",
" <artifactId>guice</artifactId>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>",
);
assert_eq!(out, expected);
// A second exclusion joins the first; excluding it again changes nothing.
let twice = exclude(&out, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap();
assert!(twice.contains(
" </exclusion>
<exclusion>
<groupId>aopalliance</groupId>
<artifactId>aopalliance</artifactId>
</exclusion>
</exclusions>"
));
assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice);
}
#[test]
fn keeps_tabs_crlf_and_one_line_dependencies() {
let tabs = "<project>\r\n\t<dependencies>\r\n\t\t<dependency>\r\n\t\t\t<groupId>g</groupId>\r\n\t\t\t<artifactId>a</artifactId>\r\n\t\t</dependency>\r\n\t</dependencies>\r\n</project>\r\n";
let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap();
assert!(out.contains("<artifactId>a</artifactId>\r\n\t\t\t<exclusions>\r\n\t\t\t\t<exclusion>\r\n\t\t\t\t\t<groupId>x</groupId>\r\n"));
assert!(out.contains("\t\t\t</exclusions>\r\n\t\t</dependency>"));
let inline = "<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId></dependency></dependencies></project>";
assert_eq!(
exclude(inline, ("g", "a"), ("x", "y")).unwrap(),
"<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId><exclusions><exclusion><groupId>x</groupId><artifactId>y</artifactId></exclusion></exclusions></dependency></dependencies></project>"
);
}
#[test]
fn wildcards_profiles_and_missing_declarations() {
let pom = "<project>
<profiles>
<profile>
<dependencies>
<dependency>
<groupId>g</groupId><artifactId>a</artifactId>
<exclusions><exclusion><groupId>x</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
</dependencies>
</profile>
</profiles>
</project>";
let key = ArtifactKey::new("g", "a");
assert_eq!(add_exclusion(pom, &key, &ArtifactKey::new("x", "y")), Some(Exclusion::Present));
let out = exclude(pom, ("g", "a"), ("z", "w")).unwrap();
assert!(out.contains(
"</exclusion><exclusion><groupId>z</groupId><artifactId>w</artifactId></exclusion></exclusions>"
));
assert_eq!(add_exclusion(pom, &ArtifactKey::new("g", "other"), &key), None);
assert_eq!(add_exclusion("not xml", &key, &key), None);
}
#[test]
fn declaration_ranges_use_utf16_columns() {
let pom = "<project><!-- é😀 --><dependencies>\n <dependency><groupId>g</groupId><artifactId>a</artifactId></dependency>\n</dependencies></project>";
let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap();
assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60)));
let first = "<project><!-- é😀 --><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId>";
let r = declaration(&format!("{first}</dependency></dependencies></project>"), &ArtifactKey::new("g", "a"))
.unwrap();
let start = first.find("<artifactId>").unwrap();
assert_eq!(r.start.col as usize, first[..start].encode_utf16().count());
}
#[test]
fn declaring_poms_follow_parents_inside_the_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let write = |rel: &str, text: &str| {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
};
write(
"pom.xml",
"<project><groupId>d</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
"app/pom.xml",
"<project><parent><groupId>d</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>app</artifactId></project>",
);
let model = maven::load_with_repo(root, None);
assert_eq!(declaring_poms(&model, "app/pom.xml"), ["app/pom.xml", "pom.xml"]);
assert_eq!(declaring_poms(&model, "pom.xml"), ["pom.xml"]);
assert!(declaring_poms(&model, "missing/pom.xml").is_empty());
}
}
+1 -6
Переглянути файл
@@ -52,12 +52,7 @@ pub(crate) fn run(
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
ide_core::process::prepare(&mut command);
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
let (tx, rx) = mpsc::channel::<(Stream, String)>();
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
+50 -7
Переглянути файл
@@ -1,5 +1,5 @@
//! Reading the effective build from Maven / Gradle itself ("Reload" in the tool
//! windows): `mvn help:effective-pom dependency:tree` and `gradle tasks --all`,
//! windows): `mvn help:effective-pom dependency:tree -Dverbose` and `gradle tasks --all`,
//! run in the background with progress and cancellation. Results are cached
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
//! into the static model by [`crate::load`]; a changed build file marks them stale.
@@ -29,6 +29,10 @@ pub struct SyncOptions {
pub profiles: Vec<String>,
/// Extra environment (`JAVA_HOME`).
pub env: BTreeMap<String, String>,
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
pub maven_command: Option<String>,
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
pub maven_options: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -45,6 +49,9 @@ struct MavenCache {
at_ms: f64,
error: Option<String>,
projects: Vec<CachedMavenProject>,
/// The trees list omitted occurrences (`-Dverbose`); older caches are reloaded.
#[serde(default)]
verbose: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -56,6 +63,21 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
/// Offline, the project's own plugin version is used instead, since this one may not be cached.
const DEPENDENCY_TREE: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree";
/// The goals that read the effective POM into `effective` and the verbose dependency trees.
fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
vec![
"help:effective-pom".into(),
format!("-Doutput={}", effective.to_string_lossy()),
if offline { "dependency:tree".into() } else { DEPENDENCY_TREE.into() },
"-Dverbose".into(),
]
}
const GRADLE_CACHE: &str = "gradle.json";
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
@@ -106,7 +128,7 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = cache.hash != hash_files(&maven::build_files(root, model));
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
}
/// Merges the cached task list into `model`.
@@ -178,23 +200,24 @@ pub fn sync_maven(
) -> IdeResult<()> {
let model = maven::load(root);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), ..MavenCache::default() };
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
let _ = std::fs::remove_file(&effective);
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
args.extend(options.maven_options.iter().cloned());
if options.offline {
args.push("-o".into());
}
if !options.profiles.is_empty() {
args.push(format!("-P{}", options.profiles.join(",")));
}
args.push("help:effective-pom".into());
args.push(format!("-Doutput={}", effective.to_string_lossy()));
args.push("dependency:tree".into());
args.extend(maven_sync_goals(&effective, options.offline));
progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None });
let out = run(&top.command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
let command = options.maven_command.as_deref().unwrap_or(&top.command);
let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
if let Some((name, i, total)) = maven_building(line) {
let fraction = (i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0);
progress(SyncProgress { message: format!("Building model of {name}"), fraction: Some(fraction) });
@@ -271,6 +294,7 @@ mod tests {
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
@@ -321,6 +345,25 @@ mod tests {
assert!(!model.stale);
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
assert_eq!(online, ["help:effective-pom", "-Doutput=e.xml", DEPENDENCY_TREE, "-Dverbose"]);
let offline = maven_sync_goals(Path::new("e.xml"), true);
assert_eq!(offline[2], "dependency:tree");
assert!(offline.contains(&"-Dverbose".to_owned()));
}
#[test]
fn cache_written_before_omissions_existed_still_loads() {
let json = r#"{"hash":"x","at_ms":1.0,"error":null,"projects":[{"key":"g:a","plugins":[],
"dependencies":[{"groupId":"g","artifactId":"d","version":"1","type":"jar","classifier":null,
"scope":"compile","optional":false,"children":[]}]}]}"#;
let cache: MavenCache = serde_json::from_str(json).unwrap();
assert!(cache.projects[0].dependencies[0].resolved());
assert!(!cache.verbose, "reloaded to read the omitted occurrences");
}
#[test]
fn progress_and_error_lines() {
assert_eq!(
+42 -7
Переглянути файл
@@ -1,12 +1,19 @@
//! A small, forgiving XML element reader for POM files: elements, text and
//! CDATA; attributes, comments, processing instructions and DOCTYPEs are
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
pub text: String,
pub children: Vec<Element>,
/// Byte offset of the start tag's `<`.
pub start: usize,
/// Byte offset of the end tag's `<` (`end` for an empty or unclosed element).
pub inner_end: usize,
/// Byte offset just past the end tag (past the start tag for `<empty/>`).
pub end: usize,
}
impl Element {
@@ -76,6 +83,7 @@ fn local(name: &str) -> String {
pub fn parse(text: &str) -> Option<Element> {
let mut stack: Vec<Element> = vec![Element::default()];
let mut rest = text;
let at = |rest: &str| text.len() - rest.len();
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
decode_entities(rest, &mut stack.last_mut()?.text);
@@ -98,13 +106,19 @@ pub fn parse(text: &str) -> Option<Element> {
} else if let Some(body) = tag.strip_prefix("</") {
let end = body.find('>').unwrap_or(body.len());
let name = local(body[..end].trim());
if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
while stack.len() > at {
let done = stack.pop()?;
let close_start = at(tag);
rest = body.get(end + 1..).unwrap_or("");
let close_end = at(rest);
if let Some(i) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) {
while stack.len() > i {
let mut done = stack.pop()?;
// Elements closed implicitly by an outer end tag end where it starts.
let matched = stack.len() == i;
done.inner_end = close_start;
done.end = if matched { close_end } else { close_start };
stack.last_mut()?.children.push(done);
}
}
rest = body.get(end + 1..).unwrap_or("");
} else {
// Start or empty tag; skip quoted attribute values when looking for `>`.
let bytes = tag.as_bytes();
@@ -124,17 +138,22 @@ pub fn parse(text: &str) -> Option<Element> {
let empty = inner.ends_with('/');
let inner = inner.trim_end_matches('/');
let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len());
let el = Element { name: local(&inner[..name_end]), ..Element::default() };
let start = at(tag);
rest = tag.get(i + 1..).unwrap_or("");
let after = at(rest);
let el =
Element { name: local(&inner[..name_end]), start, inner_end: after, end: after, ..Element::default() };
if empty {
stack.last_mut()?.children.push(el);
} else {
stack.push(el);
}
rest = tag.get(i + 1..).unwrap_or("");
}
}
while stack.len() > 1 {
let done = stack.pop()?;
let mut done = stack.pop()?;
done.inner_end = text.len();
done.end = text.len();
stack.last_mut()?.children.push(done);
}
stack.pop()?.children.into_iter().next()
@@ -167,6 +186,22 @@ mod tests {
assert_eq!(root.children_named("name").count(), 1);
}
#[test]
fn element_offsets() {
let doc = "<a>\n <b>x</b><c/>\n</a>";
let root = parse(doc).unwrap();
assert_eq!((root.start, root.inner_end, root.end), (0, doc.len() - 4, doc.len()));
let b = root.child("b").unwrap();
assert_eq!(&doc[b.start..b.end], "<b>x</b>");
assert_eq!(&doc[b.start..b.inner_end], "<b>x");
let c = root.child("c").unwrap();
assert_eq!(&doc[c.start..c.end], "<c/>");
let broken = parse("<a><b>x</a>").unwrap();
let b = broken.child("b").unwrap();
assert_eq!((b.inner_end, b.end), (7, 7));
assert_eq!(broken.end, 11);
}
#[test]
fn tolerates_broken_documents() {
let root = parse("<a><b>x</c><d>y").unwrap();
+24
Переглянути файл
@@ -0,0 +1,24 @@
[INFO] --- dependency:3.8.1:tree (default-cli) @ conflict-demo ---
[INFO] demo:conflict-demo:jar:1.0-SNAPSHOT
[INFO] +- org.hamcrest:hamcrest-core:jar:1.3:compile
[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile
[INFO] | \- org.apache.commons:commons-lang3:jar:3.12.0:compile
[INFO] +- org.apache.commons:commons-configuration2:jar:2.10.1:compile
[INFO] | +- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.12.0)
[INFO] | +- (org.apache.commons:commons-text:jar:1.11.0:compile - omitted for conflict with 1.10.0)
[INFO] | \- commons-logging:commons-logging:jar:1.2:compile (version managed from 1.3.0)
[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile
[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile
[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] | +- com.google.code.findbugs:jsr305:jar:3.0.2:compile
[INFO] | +- org.checkerframework:checker-qual:jar:3.37.0:compile
[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.21.1:compile
[INFO] | \- com.google.j2objc:j2objc-annotations:jar:2.8:compile
[INFO] +- com.google.inject:guice:jar:5.1.0:compile
[INFO] | +- javax.inject:javax.inject:jar:1:compile
[INFO] | +- aopalliance:aopalliance:jar:1.0:compile
[INFO] | \- (com.google.guava:guava:jar:30.1-jre:compile - omitted for conflict with 32.1.3-jre)
[INFO] \- junit:junit:jar:4.13.2:test
[INFO] \- (org.hamcrest:hamcrest-core:jar:1.3:test - omitted for duplicate)
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
+8
Переглянути файл
@@ -0,0 +1,8 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+293
Переглянути файл
@@ -0,0 +1,293 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both).
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
//! in full rather than rendered on demand, so no window or message loop is
//! needed and the list survives after `CloseClipboard`.
//!
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
//! [`set_files`] fails there.
use std::path::PathBuf;
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
pub fn files() -> Vec<PathBuf> {
imp::files()
}
/// Whether the clipboard holds a file list, without reading it.
pub fn has_files() -> bool {
imp::has_files()
}
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
/// to write; the clipboard is a shared resource, so callers treat this as
/// best-effort.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
}
imp::set_files(paths)
}
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
/// page survive. Empty paths are skipped.
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
let mut units: Vec<u16> = Vec::new();
for path in paths {
let path = path.to_string_lossy();
if path.is_empty() {
continue;
}
units.extend(path.encode_utf16());
units.push(0);
}
// The list ends with an empty string: with one path, two NULs in a row.
units.push(0);
let mut out = Vec::with_capacity(HEADER + units.len() * 2);
out.extend_from_slice(&(HEADER as u32).to_le_bytes()); // pFiles: list starts after the header
out.extend_from_slice(&[0u8; 8]); // pt: the drop point, unused
out.extend_from_slice(&0u32.to_le_bytes()); // fNC: not a file manager drop
out.extend_from_slice(&1u32.to_le_bytes()); // fWide: UTF-16 paths
for unit in units {
out.extend_from_slice(&unit.to_le_bytes());
}
out
}
/// The paths of a `DROPFILES` block, stopping at the empty string that ends the
/// list. An ANSI list (pre-Unicode writers) is decoded as UTF-8, lossily.
pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
if bytes.len() < HEADER {
return Vec::new();
}
let offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
let wide = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) != 0;
if offset >= bytes.len() {
return Vec::new();
}
let body = &bytes[offset..];
let mut out = Vec::new();
if wide {
let mut current: Vec<u16> = Vec::new();
for chunk in body.as_chunks::<2>().0 {
let unit = u16::from_le_bytes(*chunk);
if unit != 0 {
current.push(unit);
} else if current.is_empty() {
break;
} else {
out.push(PathBuf::from(String::from_utf16_lossy(&current)));
current.clear();
}
}
} else {
for part in body.split(|b| *b == 0) {
if part.is_empty() {
break;
}
out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned()));
}
}
out
}
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
use std::time::Duration;
/// `CF_HDROP`, the clipboard format holding a file list.
const CF_HDROP: u32 = 15;
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
type Handle = *mut c_void;
const GMEM_MOVEABLE: u32 = 0x0002;
/// Another process holds the clipboard only while it copies; retrying beats
/// reporting "no files" for a paste the user just made.
const OPEN_ATTEMPTS: usize = 10;
const OPEN_RETRY: Duration = Duration::from_millis(20);
#[link(name = "user32")]
unsafe extern "system" {
fn OpenClipboard(owner: Handle) -> i32;
fn CloseClipboard() -> i32;
fn EmptyClipboard() -> i32;
fn GetClipboardData(format: u32) -> Handle;
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
fn IsClipboardFormatAvailable(format: u32) -> i32;
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GlobalAlloc(flags: u32, bytes: usize) -> Handle;
fn GlobalLock(memory: Handle) -> *mut c_void;
fn GlobalUnlock(memory: Handle) -> i32;
fn GlobalFree(memory: Handle) -> Handle;
fn GlobalSize(memory: Handle) -> usize;
}
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
}
false
}
pub fn has_files() -> bool {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Vec::new()
} else {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let data = GlobalLock(memory);
if data.is_null() {
Vec::new()
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
paths
}
}
};
unsafe { CloseClipboard() };
paths
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
return false;
}
// SAFETY: the block is allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
if memory.is_null() {
return false;
}
let data = GlobalLock(memory);
if data.is_null() {
GlobalFree(memory);
return false;
}
ptr::copy_nonoverlapping(bytes.as_ptr(), data as *mut u8, bytes.len());
GlobalUnlock(memory);
if !open() {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
return false;
}
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
CloseClipboard();
if !taken {
GlobalFree(memory);
}
taken
}
}
}
#[cfg(not(windows))]
mod imp {
use std::path::PathBuf;
pub fn has_files() -> bool {
false
}
pub fn files() -> Vec<PathBuf> {
Vec::new()
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(list: &[&str]) -> Vec<PathBuf> {
list.iter().map(PathBuf::from).collect()
}
#[test]
fn a_written_list_reads_back_unchanged() {
let list = paths(&["C:/work/project/a.txt", "C:/work/project/sub"]);
assert_eq!(decode(&encode(&list)), list);
}
#[test]
fn the_header_says_where_the_paths_start_and_that_they_are_wide() {
let bytes = encode(&paths(&["C:/a"]));
assert_eq!(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), HEADER as u32);
assert_eq!(u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), 1);
// `C:/a` plus its NUL, then the empty string that ends the list.
assert_eq!(bytes.len(), HEADER + (4 + 1 + 1) * 2);
assert_eq!(&bytes[HEADER + 6..HEADER + 8], b"a\0");
assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]);
}
#[test]
fn names_outside_the_ansi_code_page_survive() {
let list = paths(&["C:/工作/文档/测试.txt", "C:/a/"]);
assert_eq!(decode(&encode(&list)), list);
}
#[test]
fn an_empty_list_has_no_paths() {
assert_eq!(decode(&encode(&[])), Vec::<PathBuf>::new());
// A lone terminator is enough for the reader to stop.
assert_eq!(decode(&[0u8; HEADER]), Vec::<PathBuf>::new());
}
#[test]
fn a_truncated_or_foreign_block_reads_as_no_paths() {
assert_eq!(decode(&[]), Vec::<PathBuf>::new());
assert_eq!(decode(&[1, 2, 3]), Vec::<PathBuf>::new());
let mut far = encode(&paths(&["C:/a"]));
far[0] = 0xff; // pFiles past the end
assert_eq!(decode(&far), Vec::<PathBuf>::new());
}
#[test]
fn an_ansi_list_is_still_read_before_the_terminator() {
let mut bytes = vec![0u8; HEADER];
bytes[..4].copy_from_slice(&(HEADER as u32).to_le_bytes());
bytes[16] = 0; // fWide: the list is single-byte
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
}
}
+1
Переглянути файл
@@ -10,5 +10,6 @@ dunce.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
+84 -13
Переглянути файл
@@ -39,7 +39,7 @@ impl Default for EditorGeneralSettings {
caret_scroll: "minimize".into(),
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
soft_wrap_indent: true,
scroll_past_end: true,
scroll_past_end: false,
strip_trailing_scope: "modified".into(),
keep_trailing_on_caret_line: true,
remove_trailing_blank_lines: false,
@@ -102,7 +102,7 @@ pub struct BreadcrumbsSettings {
impl Default for BreadcrumbsSettings {
fn default() -> Self {
Self { show: false, placement: "bottom".into(), disabled_languages: Vec::new() }
Self { show: true, placement: "bottom".into(), disabled_languages: Vec::new() }
}
}
@@ -117,6 +117,12 @@ pub struct CompletionSettings {
pub auto_popup: bool,
/// Insert the selected suggestion by pressing space, dot, or other context-dependent keys.
pub insert_on_special_keys: bool,
/// Insert a sole suggestion of explicit (⌃Space) completion right away.
pub auto_insert_single: bool,
/// Sort suggestions by name instead of relevance.
pub sort_alphabetically: bool,
/// Add `()` after function and method suggestions.
pub insert_parentheses: bool,
pub show_documentation: bool,
pub documentation_delay: u32,
pub parameter_info: bool,
@@ -130,10 +136,13 @@ impl Default for CompletionSettings {
case_sensitivity: "first".into(),
auto_popup: true,
insert_on_special_keys: false,
show_documentation: true,
auto_insert_single: true,
sort_alphabetically: false,
insert_parentheses: true,
show_documentation: false,
documentation_delay: 500,
parameter_info: true,
parameter_info_delay: 300,
parameter_info_delay: 1000,
}
}
}
@@ -166,13 +175,22 @@ impl Default for FoldingSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct EditorTabsSettings {
/// `top` | `none`.
/// `top` | `bottom` | `none`.
pub placement: String,
pub multiple_rows: bool,
/// One row that doesn't fit: `scroll` the tabs panel | `squeeze` the tabs.
pub one_row_overflow: String,
pub pinned_separate_row: bool,
pub show_file_icon: bool,
pub show_extension: bool,
pub show_directory_for_non_unique: bool,
/// Mark modified tabs with an asterisk.
pub mark_modified: bool,
pub show_full_path_on_hover: bool,
pub show_close_button: bool,
/// `right` | `left` (with `show_close_button`).
pub close_button_position: String,
pub sort_alphabetically: bool,
pub preview_tab: bool,
pub open_at_end: bool,
pub tab_limit: u32,
@@ -187,14 +205,21 @@ impl Default for EditorTabsSettings {
Self {
placement: "top".into(),
multiple_rows: false,
one_row_overflow: "scroll".into(),
pinned_separate_row: false,
show_file_icon: true,
show_extension: true,
show_directory_for_non_unique: true,
mark_modified: false,
show_full_path_on_hover: true,
show_close_button: true,
close_button_position: "right".into(),
sort_alphabetically: false,
preview_tab: true,
open_at_end: false,
tab_limit: 10,
closing_policy: "unchanged".into(),
activate_on_close: "mru".into(),
tab_limit: 30,
closing_policy: "lru".into(),
activate_on_close: "left".into(),
}
}
}
@@ -214,7 +239,7 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Backspace › Unindent: `nearest` indent position | `none`.
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -226,10 +251,10 @@ impl Default for SmartKeysSettings {
pair_brackets: true,
pair_quotes: true,
surround_selection: true,
tab_out_of_pair: false,
reformat_on_paste: "indentBlock".into(),
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
smart_indent: true,
backspace_unindent: "nearest".into(),
backspace_unindent: "autoindent".into(),
}
}
}
@@ -275,11 +300,38 @@ pub struct InlayHintsSettings {
pub disabled_languages: Vec<String>,
/// Code vision: usages above declarations.
pub code_vision_usages: bool,
/// Code vision: last author of a declaration (VCS blame) beside its usages.
pub code_vision_author: bool,
}
impl Default for InlayHintsSettings {
fn default() -> Self {
Self { types: true, parameters: true, other: true, disabled_languages: Vec::new(), code_vision_usages: true }
Self {
types: true,
parameters: true,
other: true,
disabled_languages: Vec::new(),
code_vision_usages: true,
code_vision_author: true,
}
}
}
/// Editor › General › Inline Completion: ghost text from language servers that
/// support `textDocument/inlineCompletion` (LSP 3.18).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct InlineCompletionSettings {
pub enabled: bool,
/// Ask only once typing has paused for `pause_ms`, instead of right after each change.
pub only_on_pause: bool,
pub pause_ms: u32,
pub disabled_languages: Vec<String>,
}
impl Default for InlineCompletionSettings {
fn default() -> Self {
Self { enabled: true, only_on_pause: false, pause_ms: 500, disabled_languages: Vec::new() }
}
}
@@ -314,6 +366,12 @@ pub struct LanguageCodeStyle {
pub indent: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hard_wrap_at: Option<u32>,
/// Comment Code › Line comment at first column.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_comment_at_first_column: Option<bool>,
/// Comment Code › Add a space at line comment start.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_comment_add_space: Option<bool>,
}
/// Editor › Code Style (General tab + per-language overrides).
@@ -329,6 +387,14 @@ pub struct CodeStyleSettings {
/// Enable EditorConfig support.
pub editorconfig: bool,
pub languages: BTreeMap<String, LanguageCodeStyle>,
/// Formatter › Do not format: globs (`*.min.js`, `generated/**`) Reformat Code skips.
pub do_not_format: Vec<String>,
/// Formatter › Turn formatter on/off with markers in code comments.
pub formatter_tags_enabled: bool,
pub formatter_off_tag: String,
pub formatter_on_tag: String,
/// The markers are regular expressions.
pub formatter_tags_regexp: bool,
}
impl Default for CodeStyleSettings {
@@ -340,6 +406,11 @@ impl Default for CodeStyleSettings {
detect_indents: true,
editorconfig: true,
languages: BTreeMap::new(),
do_not_format: Vec::new(),
formatter_tags_enabled: false,
formatter_off_tag: "@formatter:off".into(),
formatter_on_tag: "@formatter:on".into(),
formatter_tags_regexp: false,
}
}
}
+35
Переглянути файл
@@ -32,6 +32,11 @@ pub enum IdeEvent {
doc: DocId,
dirty: bool,
},
/// An open document's language changed (Settings › File Types, Override File Type).
DocLanguage {
doc: DocId,
language: crate::LanguageId,
},
/// Full replacement of diagnostics for one file from one producer.
Diagnostics {
path: crate::IpcPath,
@@ -62,6 +67,11 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
server: ServerId,
},
/// A language server asked clients to re-request one kind of data for its
/// open documents (`workspace/inlayHint/refresh`, …), e.g. after indexing.
LspRefresh {
@@ -95,6 +105,28 @@ pub enum IdeEvent {
ProjectRootsChanged {
root: crate::IpcPath,
},
/// A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`.
Askpass {
id: u64,
prompt: String,
kind: AskpassKind,
},
/// Prompt `id` was answered (in any window) or gave up.
AskpassDone {
id: u64,
},
}
/// What an askpass prompt asks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AskpassKind {
/// Password or passphrase: hidden input.
Secret,
/// User name: visible input.
Text,
/// Yes / no (an unknown host key); answered with `yes` or `no`.
Confirm,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -106,6 +138,9 @@ pub enum ServerStatus {
Crashed,
Stopped,
Missing,
/// Not started: its configuration can't launch it (e.g. no JDK new enough);
/// the message says what to change, and a settings change retries.
Misconfigured,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+106
Переглянути файл
@@ -0,0 +1,106 @@
//! "Ignored Files and Folders" (IDEA: Settings › File Types): file names the IDE
//! treats as nonexistent — hidden from the project view and navigation, never
//! indexed or searched, never offered as unversioned changes.
/// IDEA's default list, plus `.jj` and the Windows shell files.
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
"*.pyc",
"*.pyo",
"*.rbc",
"*.yarb",
"*~",
".DS_Store",
".git",
".hg",
".jj",
".svn",
"CVS",
"__pycache__",
"_svn",
"vssver.scc",
"vssver2.scc",
"Thumbs.db",
"desktop.ini",
];
/// Matches single path segments against name patterns (`*` and `?` wildcards).
#[derive(Clone, Debug, Default)]
pub struct IgnoredNames {
exact: Vec<String>,
wildcards: Vec<String>,
}
impl IgnoredNames {
pub fn new(patterns: &[String]) -> Self {
let mut out = Self::default();
for p in patterns.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.contains('/')) {
if p.contains(['*', '?']) {
out.wildcards.push(p.to_owned());
} else {
out.exact.push(p.to_owned());
}
}
out
}
pub fn is_ignored_name(&self, name: &str) -> bool {
self.exact.iter().any(|e| e == name) || self.wildcards.iter().any(|w| wildcard(w.as_bytes(), name.as_bytes()))
}
/// Whether any segment of a `/`-separated relative path is ignored.
pub fn is_ignored_path(&self, rel: &str) -> bool {
rel.split('/').any(|seg| !seg.is_empty() && self.is_ignored_name(seg))
}
}
fn wildcard(p: &[u8], s: &[u8]) -> bool {
let (mut pi, mut si, mut star, mut mark) = (0, 0, None, 0);
while si < s.len() {
if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < p.len() && p[pi] == b'*' {
star = Some(pi);
pi += 1;
mark = si;
} else if let Some(st) = star {
pi = st + 1;
mark += 1;
si = mark;
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == b'*')
}
#[cfg(test)]
mod tests {
use super::*;
fn defaults() -> IgnoredNames {
IgnoredNames::new(&DEFAULT_IGNORED_NAMES.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn matches_names_and_wildcards() {
let m = defaults();
for name in [".DS_Store", "__pycache__", "a.pyc", "notes.txt~", "Thumbs.db"] {
assert!(m.is_ignored_name(name), "{name}");
}
for name in ["DS_Store", "a.py", "pyc", "src", ".gitignore"] {
assert!(!m.is_ignored_name(name), "{name}");
}
assert!(wildcard(b"a?c*", b"abcdef"));
assert!(!wildcard(b"a?c", b"abcd"));
}
#[test]
fn matches_any_path_segment() {
let m = defaults();
assert!(m.is_ignored_path("src/.DS_Store"));
assert!(m.is_ignored_path("pkg/__pycache__/"));
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
assert!(!m.is_ignored_path("src/main.rs"));
}
}
+114
Переглянути файл
@@ -0,0 +1,114 @@
//! Settings › Editor › Inspections: inspection profiles. `Default` and the
//! user's IDE-level copies live in the IDE (global) settings, `Project Default`
//! and project-level copies in the project's; the project picks which one is in
//! effect. The UI applies the profile to diagnostics. Also Editor › Natural
//! Languages › Spelling (the Typo inspection's dictionaries).
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct InspectionSettings {
/// Profile in effect for the project: `default`, `project`, or a copy
/// as `ide:<key>` / `project:<key>`.
pub profile: String,
/// The `Default` profile (IDE level): inspection id → override.
pub default: BTreeMap<String, InspectionOverride>,
/// The `Project Default` profile (stored with the project).
pub project: BTreeMap<String, InspectionOverride>,
/// Profiles copied at IDE level (global settings), by key.
pub ide_profiles: BTreeMap<String, CustomInspectionProfile>,
/// Profiles copied at project level (project settings), by key.
pub project_profiles: BTreeMap<String, CustomInspectionProfile>,
}
impl Default for InspectionSettings {
fn default() -> Self {
Self {
profile: "default".into(),
default: BTreeMap::new(),
project: BTreeMap::new(),
ide_profiles: BTreeMap::new(),
project_profiles: BTreeMap::new(),
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CustomInspectionProfile {
pub name: String,
pub overrides: BTreeMap<String, InspectionOverride>,
}
/// Editor › Natural Languages › Spelling.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct SpellingSettings {
/// Application-level dictionary: accepted words (IDE settings).
pub ide_words: Vec<String>,
/// Project-level dictionary: accepted words (project settings).
pub project_words: Vec<String>,
/// Custom dictionaries: plain word lists or Hunspell `.dic` files.
pub custom_dictionaries: Vec<String>,
}
/// Changes to one inspection; absent fields keep the inspection's defaults.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct InspectionOverride {
#[specta(optional)]
pub enabled: Option<bool>,
#[specta(optional)]
pub severity: Option<InspectionLevel>,
/// The inspection's own options (Typo: `processCode`, `processLiterals`,
/// `processComments`); absent ones keep their defaults.
#[specta(optional)]
pub options: Option<BTreeMap<String, bool>>,
}
/// IDEA's highlighting levels.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum InspectionLevel {
Error,
Warning,
WeakWarning,
ServerProblem,
Typo,
/// Reported (Problems, Inspect Code) but not highlighted in the editor.
None,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overrides_parse_partially() {
let s: InspectionSettings = serde_json::from_str(
r#"{"default":{"lsp:ruff":{"enabled":false},"lsp:ruff:F401":{"severity":"weakWarning"}}}"#,
)
.unwrap();
assert_eq!(s.profile, "default");
assert_eq!(s.default["lsp:ruff"].enabled, Some(false));
assert_eq!(s.default["lsp:ruff:F401"].severity, Some(InspectionLevel::WeakWarning));
assert!(s.project.is_empty());
assert!(s.ide_profiles.is_empty());
let s: InspectionSettings = serde_json::from_str(
r#"{"profile":"ide:strict","ideProfiles":{"strict":{"name":"Strict","overrides":{"lsp:ruff":{"severity":"error"}}}}}"#,
)
.unwrap();
assert_eq!(s.ide_profiles["strict"].name, "Strict");
assert_eq!(s.ide_profiles["strict"].overrides["lsp:ruff"].severity, Some(InspectionLevel::Error));
assert!(serde_json::from_str::<InspectionSettings>(r#"{"default":{"x":{"severity":"loud"}}}"#).is_err());
let s: InspectionSettings =
serde_json::from_str(r#"{"default":{"builtin:spelling":{"options":{"processCode":false}}}}"#).unwrap();
assert!(!s.default["builtin:spelling"].options.as_ref().unwrap()["processCode"]);
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
}
+4
Переглянути файл
@@ -8,8 +8,12 @@ pub mod editor_settings;
pub mod error;
pub mod event;
pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod process;
pub mod project_dir;
pub mod remote_settings;
pub mod settings;
pub mod text;
pub mod workflow_settings;
+256
Переглянути файл
@@ -0,0 +1,256 @@
//! Child-process helpers shared by every crate that spawns tools.
use std::ffi::{OsStr, OsString};
use std::process::Command;
use std::sync::OnceLock;
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Readies `cmd` for spawning any child (java, node, curl, git, a language server, ...).
/// For a `tokio::process::Command`, pass `cmd.as_std_mut()`.
///
/// - Windows: release builds use the GUI subsystem, so without `CREATE_NO_WINDOW` every
/// console child flashes a `cmd` window.
/// - Linux AppImage: see [`host_env`].
///
/// Variables the call site has already set are kept; call it before `env_clear`.
pub fn prepare(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
host_env(cmd)
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
/// (WebKit and GTK still need it). Variables the call site has already set are kept.
/// A no-op outside an AppImage.
pub fn host_env(cmd: &mut Command) -> &mut Command {
let fixes = appimage_fixes();
if fixes.is_empty() {
return cmd;
}
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
for (key, value) in fixes.into_iter().filter(|(k, _)| !explicit.contains(k)) {
match value {
Some(v) => cmd.env(key, v),
None => cmd.env_remove(key),
};
}
cmd
}
/// The [`host_env`] changes as `(name, value)` pairs, `None` meaning "remove", for spawners
/// that are not a `std::process::Command` (a PTY `CommandBuilder`). Apply before the call
/// site's own variables. Empty outside an AppImage.
pub fn host_env_fixes() -> Vec<(OsString, Option<OsString>)> {
appimage_fixes()
}
/// This process's environment as a child should inherit it (see [`host_env`]), for call
/// sites that build a child's environment from the IDE's own instead of inheriting it.
pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
let fixes = appimage_fixes();
std::env::vars_os().filter_map(move |(k, v)| match fixes.iter().find(|(f, _)| *f == k) {
Some((_, fixed)) => fixed.clone().map(|v| (k, v)),
None => Some((k, v)),
})
}
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
let key = key.as_ref();
match appimage_fixes().into_iter().find(|(k, _)| k == key) {
Some((_, fixed)) => fixed,
None => std::env::var_os(key),
}
}
/// Read on every call, not cached: startup rewrites `PATH` (login shell, managed tools).
fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
if !cfg!(target_os = "linux") || std::env::var_os("APPDIR").is_none() {
return Vec::new();
}
appimage_env_fixes(&std::env::vars_os().collect::<Vec<_>>())
}
/// Variables the AppImage runtime sets to describe the IDE's own launch.
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
/// What to change in `vars` (an environment) so a child sees the host's environment rather
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
let Some(appdir) =
get("APPDIR").and_then(|v| v.to_str()).map(|v| v.trim_end_matches('/')).filter(|v| !v.is_empty())
else {
return Vec::new();
};
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let mut fixes = Vec::new();
for (key, value) in vars {
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
let fixed = if APPIMAGE_LAUNCH_VARS.contains(&name) {
None
} else if value.split(':').any(inside) {
match get(&format!("{name}_ORIG")) {
Some(orig) => Some(orig.clone()),
None => {
let kept: Vec<&str> = value.split(':').filter(|e| !e.is_empty() && !inside(e)).collect();
Some(OsString::from(kept.join(":")))
}
}
} else if name == "GTK_THEME" && value.is_empty() {
// linuxdeploy-plugin-gtk exports `GTK_THEME="$APPIMAGE_GTK_THEME"`, usually empty.
None
} else {
continue;
};
fixes.push((key.clone(), fixed.filter(|v| !v.is_empty())));
}
fixes
}
static SHELL_ENV: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
/// Records the login shell's environment, read once at startup off the main thread.
pub fn set_shell_env(vars: Vec<(OsString, OsString)>) {
let _ = SHELL_ENV.set(vars);
}
/// The login shell's variables this process lacks: an app started from Finder/Dock gets only
/// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...).
/// Apply before a call site's own variables so those win. Empty until the shell has been read.
pub fn shell_env() -> impl Iterator<Item = (&'static OsStr, &'static OsStr)> {
SHELL_ENV
.get()
.into_iter()
.flatten()
.filter(|(k, _)| std::env::var_os(k).is_none())
.map(|(k, v)| (k.as_os_str(), v.as_os_str()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_env_adds_only_what_the_process_lacks() {
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
let vars: Vec<_> = shell_env().collect();
assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]);
}
fn env(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> {
pairs.iter().map(|(k, v)| (OsString::from(k), OsString::from(v))).collect()
}
fn fixes(pairs: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
appimage_env_fixes(&env(pairs))
.into_iter()
.map(|(k, v)| (k.into_string().unwrap(), v.map(|v| v.into_string().unwrap())))
.collect()
}
fn fix(name: &str, value: Option<&str>) -> (String, Option<String>) {
(name.to_owned(), value.map(str::to_owned))
}
#[test]
fn appimage_entries_are_dropped_and_host_entries_kept() {
let got = fixes(&[
("APPDIR", "/tmp/.mount_OpenIDabc"),
(
"LD_LIBRARY_PATH",
"/tmp/.mount_OpenIDabc/usr/lib/:/tmp/.mount_OpenIDabc/usr/lib/x86_64-linux-gnu/:/opt/cuda/lib64:/usr/local/lib",
),
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
("HOME", "/home/me"),
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
]);
assert_eq!(
got,
[
fix("APPDIR", None),
fix("LD_LIBRARY_PATH", Some("/opt/cuda/lib64:/usr/local/lib")),
fix("PATH", Some("/usr/local/bin:/usr/bin")),
fix("XDG_DATA_DIRS", Some("/usr/share:/usr/local/share:/usr/share")),
]
);
}
#[test]
fn variables_left_empty_are_removed() {
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
let got = fixes(&[
("APPIMAGE", "/opt/openidea/OpenIDEA.AppImage"),
("APPDIR", "/tmp/.mount_x/"),
("ARGV0", "/opt/openidea/OpenIDEA.AppImage"),
("OWD", "/home/me"),
("LD_LIBRARY_PATH", "/tmp/.mount_x/usr/lib/:/tmp/.mount_x/lib/:"),
("GIO_MODULE_DIR", "/tmp/.mount_x/usr/lib/gio/modules"),
("GTK_DATA_PREFIX", "/tmp/.mount_x"),
("GDK_PIXBUF_MODULE_FILE", "/tmp/.mount_x/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"),
("GST_PLUGIN_SYSTEM_PATH_1_0", "/tmp/.mount_x/usr/lib/gstreamer-1.0"),
("GTK_THEME", ""),
]);
let names: Vec<_> = got.iter().filter(|(_, v)| v.is_none()).map(|(k, _)| k.as_str()).collect();
assert_eq!(
names,
[
"APPIMAGE",
"APPDIR",
"ARGV0",
"OWD",
"LD_LIBRARY_PATH",
"GIO_MODULE_DIR",
"GTK_DATA_PREFIX",
"GDK_PIXBUF_MODULE_FILE",
"GST_PLUGIN_SYSTEM_PATH_1_0",
"GTK_THEME",
]
);
assert_eq!(names.len(), got.len());
}
#[test]
fn a_saved_original_value_wins() {
let got = fixes(&[
("APPDIR", "/tmp/.mount_x"),
("LD_LIBRARY_PATH", "/tmp/.mount_x/usr/lib:/opt/lib"),
("LD_LIBRARY_PATH_ORIG", "/opt/lib:/home/me/lib"),
("PYTHONHOME", "/tmp/.mount_x/usr"),
("PYTHONHOME_ORIG", ""),
]);
assert_eq!(
got,
[fix("APPDIR", None), fix("LD_LIBRARY_PATH", Some("/opt/lib:/home/me/lib")), fix("PYTHONHOME", None)]
);
}
#[test]
fn outside_an_appimage_nothing_changes() {
let plain = [("LD_LIBRARY_PATH", "/opt/lib:"), ("PATH", "/usr/bin"), ("GTK_THEME", "")];
assert!(fixes(&plain).is_empty());
assert!(fixes(&[("APPDIR", ""), ("PATH", "/usr/bin")]).is_empty());
assert!(fixes(&[("APPDIR", "/"), ("PATH", "/usr/bin")]).is_empty());
}
#[test]
fn host_env_keeps_what_the_call_site_set() {
let mut cmd = Command::new("true");
cmd.env("PATH", "/custom");
prepare(&mut cmd);
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
}
}
+150
Переглянути файл
@@ -0,0 +1,150 @@
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
/// How an SSH connection authenticates.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SshAuth {
Password,
/// A private key file, optionally protected by a passphrase.
KeyPair,
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
#[default]
OpenSsh,
}
/// Tools › SSH Configurations.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct SshSettings {
pub configs: Vec<SshConfiguration>,
}
/// A named SSH host, shared by terminals, deployment, run targets and data sources.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct SshConfiguration {
pub id: String,
pub name: String,
/// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`].
pub host: String,
pub port: Option<u16>,
pub user: String,
pub auth: SshAuth,
/// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory.
pub key_file: String,
/// Keep the password / key passphrase in the saved passwords.
pub save_secret: bool,
}
impl SshConfiguration {
/// `user@host:port` with what is known (the name when the host is empty).
pub fn label(&self) -> String {
let host = self.host.trim();
if host.is_empty() {
return self.name.clone();
}
let mut s = String::new();
if !self.user.trim().is_empty() {
s.push_str(self.user.trim());
s.push('@');
}
s.push_str(host);
if let Some(p) = self.port {
s.push_str(&format!(":{p}"));
}
s
}
}
/// When local changes are uploaded to the default deployment server.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AutoUpload {
#[default]
Never,
/// Files saved with Save / Save All (⌘S).
OnExplicitSave,
/// Every save, including autosave and external changes.
Always,
}
/// Build, Execution, Deployment › Deployment (and its Options page).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String,
pub auto_upload: AutoUpload,
/// Delete remote files when their local file is deleted (automatic upload).
pub delete_remote: bool,
/// Keep local modification times on uploaded files.
pub preserve_timestamps: bool,
/// File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI).
pub exclude: Vec<String>,
}
impl Default for DeploymentSettings {
fn default() -> Self {
Self {
servers: Vec::new(),
default_server: String::new(),
auto_upload: AutoUpload::Never,
delete_remote: false,
preserve_timestamps: true,
exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"]
.map(String::from)
.to_vec(),
}
}
}
/// An SFTP server and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentServer {
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Absolute remote folder the mappings are relative to; empty = the login directory.
pub root_path: String,
/// Web server URL of the root (Open in Browser on the deployed file).
pub web_url: String,
pub mappings: Vec<DeploymentMapping>,
/// Remote paths (relative to the root) never touched.
pub excluded_paths: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentMapping {
/// Project-relative folder; empty = the project root.
pub local: String,
/// Remote folder, relative to the server root (a leading `/` is the root too).
pub deployment: String,
/// Web path, relative to the server's URL.
pub web: String,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from.
pub project_root: String,
/// Upload changed project files before each run.
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
}
+227 -3
Переглянути файл
@@ -6,6 +6,8 @@ use specta::Type;
use std::collections::BTreeMap;
pub use crate::editor_settings::*;
pub use crate::inspection_settings::*;
pub use crate::remote_settings::*;
pub use crate::workflow_settings::*;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -37,6 +39,88 @@ pub struct Settings {
pub terminal: TerminalSettings,
pub actions_on_save: ActionsOnSaveSettings,
pub external_tools: Vec<ExternalTool>,
/// Language servers / debug adapters at a user-chosen location, by the
/// command the language definition launches (`elixir-ls`, `codelldb`).
pub tools: BTreeMap<String, ToolSettings>,
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
pub spelling: SpellingSettings,
/// Tools › SSH Configurations.
pub ssh: SshSettings,
/// Build, Execution, Deployment › Deployment.
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ToolSettings {
/// Executable to launch instead of the managed / `PATH` one; empty = auto-detect.
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
/// `false` keeps the server from starting for any language.
pub enabled: bool,
/// Executable to launch instead of the definition's; required for a server
/// defined here (one with `languages`).
pub command: String,
/// Replaces the definition's arguments.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub args: Option<Vec<String>>,
/// Added to the server's environment.
pub env: BTreeMap<String, String>,
/// Merged over the definition's `initializationOptions` (sent once at startup).
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional, type = Option<specta_typescript::Unknown>)]
pub initialization_options: Option<serde_json::Value>,
/// Answers the server's `workspace/configuration` requests; changes are pushed
/// with `workspace/didChangeConfiguration` without a restart.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional, type = Option<specta_typescript::Unknown>)]
pub settings: Option<serde_json::Value>,
/// Language ids a server defined here serves (`python`); empty for the servers
/// of the language definitions.
pub languages: Vec<String>,
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
pub root_markers: Vec<String>,
}
impl Default for LanguageServerSettings {
fn default() -> Self {
Self {
enabled: true,
command: String::new(),
args: None,
env: BTreeMap::new(),
initialization_options: None,
settings: None,
languages: Vec::new(),
root_markers: Vec::new(),
}
}
}
impl LanguageServerSettings {
/// A server defined in settings rather than by a language definition.
pub fn is_custom(&self) -> bool {
!self.languages.is_empty()
}
}
impl Settings {
/// The configured executable for `command`, if any (`~/` not yet expanded).
pub fn tool_path(&self, command: &str) -> Option<&str> {
self.tools.get(command).map(|t| t.path.trim()).filter(|p| !p.is_empty())
}
}
impl Default for Settings {
@@ -59,6 +143,13 @@ impl Default for Settings {
terminal: TerminalSettings::default(),
actions_on_save: ActionsOnSaveSettings::default(),
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
}
}
}
@@ -91,6 +182,11 @@ pub struct GeneralSettings {
pub save_on_idle: bool,
pub idle_seconds: u32,
pub date_format: DateFormatSettings,
/// File › Power Save Mode: no automatic popups (completion, parameter info,
/// quick documentation) or background highlighting (usages, code vision).
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
}
impl Default for GeneralSettings {
@@ -103,6 +199,8 @@ impl Default for GeneralSettings {
save_on_idle: false,
idle_seconds: 15,
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
}
}
}
@@ -192,6 +290,20 @@ pub struct AppearanceSettings {
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
/// (registered later) keep their default place.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MainToolbarSettings {
pub left: Vec<String>,
pub right: Vec<String>,
/// Widgets the user removed.
pub hidden: Vec<String>,
}
impl Default for AppearanceSettings {
@@ -218,9 +330,10 @@ impl Default for AppearanceSettings {
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: true,
show_tool_window_names: false,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
}
}
@@ -242,9 +355,18 @@ pub struct EditorSettings {
pub color_scheme: String,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
/// Fallback font for glyphs the main font lacks; empty = none.
pub fallback_font: String,
/// Typography Settings › Main font weight (CSS 100–900), as IDEA.
pub font_weight: u16,
/// Typography Settings › Bold font weight: wherever the editor renders bold.
pub bold_font_weight: u16,
pub general: EditorGeneralSettings,
pub appearance: EditorAppearanceSettings,
pub breadcrumbs: BreadcrumbsSettings,
@@ -255,6 +377,7 @@ pub struct EditorSettings {
pub smart_keys: SmartKeysSettings,
pub code_editing: CodeEditingSettings,
pub inlay: InlayHintsSettings,
pub inline_completion: InlineCompletionSettings,
/// Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`).
pub gutter_icons_disabled: Vec<String>,
pub show_gutter_icons: bool,
@@ -277,8 +400,12 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
font_weight: 400,
bold_font_weight: 700,
general: EditorGeneralSettings::default(),
appearance: EditorAppearanceSettings::default(),
breadcrumbs: BreadcrumbsSettings::default(),
@@ -289,6 +416,7 @@ impl Default for EditorSettings {
smart_keys: SmartKeysSettings::default(),
code_editing: CodeEditingSettings::default(),
inlay: InlayHintsSettings::default(),
inline_completion: InlineCompletionSettings::default(),
gutter_icons_disabled: Vec::new(),
show_gutter_icons: true,
code_style: CodeStyleSettings::default(),
@@ -303,12 +431,30 @@ pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
pub trim_trailing_whitespace: bool,
pub ensure_final_newline: bool,
/// Local History keeps changes from this many working days (days with changes), like IDEA.
pub local_history_days: u32,
/// Upper bound for a project's Local History in MB; the oldest revisions go first.
pub local_history_max_mb: u32,
/// File Types › Recognized File Types: the user's changes, by language id.
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
}
/// One language's file name and hashbang patterns on top of its definition.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FileTypeAssociation {
/// Added file name patterns: `*.foo`, `Jenkinsfile`, `Dockerfile.*` (`*` and `?` wildcards).
pub patterns: Vec<String>,
/// Patterns of the definition (`*.ext`, file names) the user removed.
pub removed_patterns: Vec<String>,
/// Interpreters run by a `#!` first line, e.g. `node` for `#!/usr/bin/env node`.
pub hashbangs: Vec<String>,
}
impl Default for FileSettings {
@@ -316,10 +462,13 @@ impl Default for FileSettings {
Self {
auto_save: true,
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
trim_trailing_whitespace: true,
ensure_final_newline: true,
local_history_days: 5,
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
}
}
}
@@ -332,6 +481,7 @@ pub struct LanguagesSettings {
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub java: JavaSettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -394,6 +544,35 @@ pub struct GoSettings {
pub gopath: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// JDK home the Java language server (jdtls) runs on, apart from the project
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
/// else the first installed one that is.
pub jdtls_jdk: String,
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct TypeScriptSettings {
@@ -420,12 +599,23 @@ pub struct ProjectStructureSettings {
pub excluded: Vec<String>,
/// Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked.
pub unmarked: Vec<String>,
/// Content roots besides the project folder (absolute paths), shown and searched with it.
pub content_roots: Vec<String>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
}
impl ProjectStructureSettings {
/// The "Exclude files" name patterns.
pub fn exclude_file_patterns(&self) -> Vec<String> {
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
}
}
impl Settings {
/// `files.exclude` plus the Project Structure excluded folders as globs.
/// `files.exclude`, the ignored names and the Project Structure excluded folders as globs.
pub fn exclude_globs(&self) -> Vec<String> {
let mut out = self.files.exclude.clone();
let mut out = self.file_excludes();
for dir in &self.project_structure.excluded {
let dir = dir.trim().trim_matches('/');
if !dir.is_empty() {
@@ -434,12 +624,35 @@ impl Settings {
}
out
}
/// `files.exclude`, `files.ignoredNames` and the Project Structure "Exclude files"
/// patterns (a bare name matches at any depth).
pub fn file_excludes(&self) -> Vec<String> {
let mut out = self.files.exclude.clone();
out.extend(self.files.ignored_names.iter().filter(|n| !n.trim().is_empty()).cloned());
out.extend(self.project_structure.exclude_file_patterns().into_iter().filter(|p| !p.contains('/')));
out
}
pub fn ignored_names(&self) -> crate::ignored::IgnoredNames {
crate::ignored::IgnoredNames::new(&self.files.ignored_names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(json["editor"]["fontWeight"], 400);
assert_eq!(json["editor"]["boldFontWeight"], 700);
let s: Settings = serde_json::from_str(r#"{"editor":{"fontWeight":300,"boldFontWeight":600}}"#).unwrap();
assert_eq!((s.editor.font_weight, s.editor.bold_font_weight), (300, 600));
assert_eq!(s.editor.font_size, 13.0);
}
#[test]
fn keymap_overrides_are_camel_case_and_default_empty() {
let s: Settings =
@@ -462,4 +675,15 @@ mod tests {
assert!(k.overrides["file.save"].is_empty());
assert_eq!(serde_json::to_value(Settings::default()).unwrap()["customKeymaps"], serde_json::json!({}));
}
#[test]
fn exclude_files_patterns_join_the_file_excludes() {
let s: Settings = serde_json::from_str(
r#"{"files":{"exclude":[],"ignoredNames":[]},"projectStructure":{"excludeFiles":" *.log ; tmp*;;"}}"#,
)
.unwrap();
assert_eq!(s.project_structure.exclude_file_patterns(), vec!["*.log", "tmp*"]);
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
assert!(Settings::default().files.file_types.is_empty());
}
}
+20
Переглянути файл
@@ -129,6 +129,11 @@ pub struct Diagnostic {
pub unnecessary: bool,
#[serde(default)]
pub deprecated: bool,
/// Language server (or tool) that reported it, e.g. `basedpyright`; `source`
/// is what the producer calls itself. Inspection profiles key on this.
#[serde(default)]
#[specta(optional)]
pub server: Option<String>,
}
/// Symbol kinds, a stable subset of LSP `SymbolKind`.
@@ -168,6 +173,17 @@ impl SymbolKind {
}
}
/// Declared (or conventional) visibility of an outline symbol.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Visibility {
Public,
Protected,
/// Package / module / assembly scope (Java default, `internal`, `pub(crate)`, Go unexported).
Package,
Private,
}
/// A node of a document outline (Structure tool window, breadcrumbs).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct OutlineItem {
@@ -179,6 +195,10 @@ pub struct OutlineItem {
/// The identifier itself; where navigation lands.
pub selection_range: Range,
pub children: Vec<OutlineItem>,
/// Unknown (`None`) for language-server symbols and languages without the notion.
#[serde(default)]
#[specta(optional)]
pub visibility: Option<Visibility>,
}
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
+62 -4
Переглянути файл
@@ -17,6 +17,7 @@ pub struct VcsSettings {
pub git: GitSettings,
pub commit: CommitSettings,
pub confirmation: VcsConfirmationSettings,
pub shelf: ShelfSettings,
/// Issue Navigation: commit-message patterns turned into links.
pub issue_navigation: Vec<IssueLink>,
}
@@ -24,12 +25,14 @@ pub struct VcsSettings {
impl Default for VcsSettings {
fn default() -> Self {
Self {
show_dirty_directories: true,
// IDEA's VcsConfiguration.SHOW_DIRTY_RECURSIVELY is off by default.
show_dirty_directories: false,
limit_history: true,
history_limit: 1000,
git: GitSettings::default(),
commit: CommitSettings::default(),
confirmation: VcsConfirmationSettings::default(),
shelf: ShelfSettings::default(),
issue_navigation: Vec::new(),
}
}
@@ -52,6 +55,17 @@ pub struct GitSettings {
pub protected_branches: String,
/// Initial state of Commit Options › Sign-off Commit.
pub sign_off: bool,
/// Ask before committing files larger than `large_file_mb`.
pub warn_large_files: bool,
pub large_file_mb: u32,
/// Ask before committing names that are invalid on Windows or differ only in case.
pub warn_bad_file_names: bool,
/// Commit and Push opens the Push dialog; otherwise it pushes right away.
pub show_push_dialog: bool,
/// With `show_push_dialog`: only for commits to protected branches.
pub push_dialog_only_protected: bool,
/// Stashes and shelves share one tab.
pub combine_stash_and_shelf: bool,
}
impl Default for GitSettings {
@@ -65,6 +79,12 @@ impl Default for GitSettings {
incoming_check: "auto".into(),
protected_branches: "master; main".into(),
sign_off: false,
warn_large_files: true,
large_file_mb: 50,
warn_bad_file_names: true,
show_push_dialog: true,
push_dialog_only_protected: false,
combine_stash_and_shelf: false,
}
}
}
@@ -73,6 +93,11 @@ impl Default for GitSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CommitSettings {
/// Commit from the Commit tool window; off = Local Changes in the Git tool
/// window and a Commit dialog.
pub non_modal: bool,
/// Start with an empty message instead of the last one / the changelist's.
pub clear_initial_message: bool,
pub analyze_code: bool,
pub check_todo: bool,
pub reformat: bool,
@@ -90,6 +115,8 @@ pub struct CommitSettings {
impl Default for CommitSettings {
fn default() -> Self {
Self {
non_modal: true,
clear_initial_message: false,
analyze_code: true,
check_todo: true,
reformat: false,
@@ -106,20 +133,40 @@ impl Default for CommitSettings {
}
}
/// Version Control › Confirmation. Values: `ask` | `silently` | `never`.
/// Version Control › Confirmation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct VcsConfirmationSettings {
/// `ask` | `silently` | `never`.
pub on_create: String,
pub on_delete: String,
/// Update Project opens its options dialog first.
pub show_update_options: bool,
pub confirm_drop_commits: bool,
/// After a patch is saved: `ask` | `no` | `reveal` (show it in the file manager).
pub on_patch_created: String,
}
impl Default for VcsConfirmationSettings {
fn default() -> Self {
Self { on_create: "ask".into(), on_delete: "ask".into() }
Self {
on_create: "ask".into(),
on_delete: "ask".into(),
show_update_options: true,
confirm_drop_commits: true,
on_patch_created: "ask".into(),
}
}
}
/// Version Control › Shelf.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ShelfSettings {
/// Initial state of Unshelve's "Remove successfully applied files from the shelf".
pub remove_applied: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct IssueLink {
@@ -211,6 +258,8 @@ impl Default for PythonDebuggerSettings {
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
pub show_on_start: bool,
/// Grace period between interrupt and kill when stopping a process.
pub termination_timeout_ms: u32,
@@ -218,7 +267,7 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self { confirm_rerun: true, show_on_start: true, termination_timeout_ms: 3000 }
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
}
}
@@ -241,6 +290,8 @@ pub struct TerminalSettings {
pub shell_integration: bool,
pub highlight_hyperlinks: bool,
pub option_as_meta: bool,
/// Escape moves focus to the editor instead of reaching the shell.
pub escape_to_editor: bool,
/// `block` | `underline` | `bar`.
pub cursor_shape: String,
pub cursor_blink: bool,
@@ -249,6 +300,10 @@ pub struct TerminalSettings {
pub font_size: f32,
/// Multiple of the cell height.
pub line_height: f32,
/// Font tried for glyphs the main font lacks; empty = none.
pub fallback_font_family: String,
/// Multiple of the cell width.
pub column_width: f32,
}
impl Default for TerminalSettings {
@@ -267,12 +322,15 @@ impl Default for TerminalSettings {
shell_integration: true,
highlight_hyperlinks: true,
option_as_meta: false,
escape_to_editor: false,
cursor_shape: "block".into(),
cursor_blink: true,
use_editor_font: true,
font_family: "JetBrains Mono".into(),
font_size: 13.0,
line_height: 1.0,
fallback_font_family: String::new(),
column_width: 1.0,
}
}
}
+173
Переглянути файл
@@ -0,0 +1,173 @@
//! "Import a report collected in CI from disk": the format is sniffed from the
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
//! machine are mapped into the project by their longest existing suffix.
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
use ide_core::{IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Format {
Lcov,
GoProfile,
Jacoco,
}
pub(crate) fn detect(text: &str) -> Option<Format> {
let head = text.trim_start_matches('\u{feff}').trim_start();
if head.starts_with('<') {
return head.contains("<report").then_some(Format::Jacoco);
}
let first = head.lines().next().unwrap_or_default();
if first.starts_with("mode:") {
return Some(Format::GoProfile);
}
head.lines().any(|l| l.trim_start().starts_with("SF:")).then_some(Format::Lcov)
}
/// Reads the report at `path` for the project at `root`. Blocking.
pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
let text = std::fs::read_to_string(path)?;
let mut suite = match detect(&text) {
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
Some(Format::GoProfile) => {
let (module, module_dir) = go_module_for(root, &text);
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
}
Some(Format::Jacoco) => {
// A report inside the project (`<module>/target/site/jacoco/…`) knows its module.
let module = jacoco::module_dir(path);
let module =
if path.starts_with(root) && module.starts_with(root) && module != path.parent().unwrap_or(path) {
module
} else {
root.to_path_buf()
};
let mut acc = Accumulator { has_branches: true, ..Default::default() };
jacoco::parse_into(&text, &module, &mut acc)
.map_err(|e| IdeError::invalid(format!("{}: {e}", path.display())))?;
acc.finish("JaCoCo")
}
None => {
return Err(IdeError::invalid(format!(
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
path.display()
)));
}
};
for f in &mut suite.files {
let p: &Path = f.path.as_ref();
if !p.exists()
&& let Some(found) = relocate(p, root)
{
f.path = found.into();
}
}
suite.files.sort_by(|a, b| (a.path.as_ref() as &Path).cmp(b.path.as_ref()));
Ok(suite)
}
/// `/home/runner/work/app/app/src/lib.rs` → `<root>/src/lib.rs`: the longest suffix of
/// `path` that exists under `root`.
pub(crate) fn relocate(path: &Path, root: &Path) -> Option<PathBuf> {
let parts: Vec<&std::ffi::OsStr> = path
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s),
_ => None,
})
.collect();
(0..parts.len()).find_map(|skip| {
let candidate = parts[skip..].iter().fold(root.to_path_buf(), |p, s| p.join(s));
candidate.is_file().then_some(candidate)
})
}
/// The Go module the profile's import paths belong to: the project's `go.mod`, else one
/// a level or two down whose module path prefixes the profile's files.
fn go_module_for(root: &Path, profile: &str) -> (String, PathBuf) {
let first = profile
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with("mode:"))
.and_then(|l| l.rsplit_once(':').map(|(f, _)| f.to_owned()))
.unwrap_or_default();
let matches = |module: &str| !module.is_empty() && (first == module || first.starts_with(&format!("{module}/")));
let read = |dir: &Path| -> Option<String> {
let text = std::fs::read_to_string(dir.join("go.mod")).ok()?;
text.lines().find_map(|l| l.trim().strip_prefix("module")).map(|m| m.trim().trim_matches('"').to_owned())
};
let mut candidates = vec![root.to_path_buf()];
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
candidates
.extend(std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()));
candidates.push(dir);
}
let found: Vec<(String, PathBuf)> = candidates.into_iter().filter_map(|d| read(&d).map(|m| (m, d))).collect();
found
.iter()
.find(|(m, _)| matches(m))
.or_else(|| found.iter().find(|(_, d)| d == root))
.cloned()
.unwrap_or_else(|| (String::new(), root.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_formats() {
assert_eq!(detect("TN:\nSF:a.rs\nDA:1,1\nend_of_record\n"), Some(Format::Lcov));
assert_eq!(detect("mode: atomic\nm/a.go:1.1,2.2 1 0\n"), Some(Format::GoProfile));
assert_eq!(
detect("<?xml version=\"1.0\"?><!DOCTYPE report><report name=\"x\"></report>"),
Some(Format::Jacoco)
);
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
assert_eq!(detect("hello"), None);
}
#[test]
fn imports_ci_lcov_with_foreign_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "").unwrap();
let report = tmp.path().join("lcov.info");
std::fs::write(&report, "SF:/home/runner/work/app/app/src/lib.rs\nDA:1,1\nDA:2,0\nend_of_record\n").unwrap();
let suite = import(&report, &root).unwrap();
assert_eq!(suite.runner, "lcov");
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/lib.rs"));
assert_eq!(suite.files[0].lines_covered, 1);
assert!(import(&tmp.path().join("missing"), &root).is_err());
std::fs::write(&report, "nothing").unwrap();
assert!(import(&report, &root).is_err());
}
#[test]
fn imports_go_profiles_of_nested_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("svc/calc")).unwrap();
std::fs::write(root.join("svc/go.mod"), "module example.com/svc\n").unwrap();
let report = root.join("cover.out");
std::fs::write(&report, "mode: set\nexample.com/svc/calc/calc.go:3.1,4.2 1 1\n").unwrap();
let suite = import(&report, root).unwrap();
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("svc/calc/calc.go"));
}
#[test]
fn imports_jacoco_against_the_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
std::fs::create_dir_all(root.join("src/main/java/com/x")).unwrap();
std::fs::write(root.join("src/main/java/com/x/A.java"), "").unwrap();
let report = tmp.path().join("jacoco.xml");
let xml = r#"<report name="p"><package name="com/x"><sourcefile name="A.java"><line nr="2" mi="0" ci="1" mb="0" cb="0"/></sourcefile></package></report>"#;
std::fs::write(&report, xml).unwrap();
let suite = import(&report, &root).unwrap();
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/main/java/com/x/A.java"));
assert!(suite.has_branches);
}
}
+3 -1
Переглянути файл
@@ -16,10 +16,12 @@
//! Lines in the model are 0-based, like every IPC coordinate.
mod goprofile;
mod import;
mod jacoco;
mod lcov;
mod plan;
pub use import::import;
pub use plan::prepare;
use ide_core::{IdeError, IdeResult, IpcPath};
@@ -204,7 +206,7 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
CoverageReportSpec::Exported { runner, argv, cwd, env, lcov } => {
let (program, args) =
argv.split_first().ok_or_else(|| IdeError::invalid("empty coverage export command"))?;
let out = Command::new(program)
let out = ide_core::process::prepare(&mut Command::new(program))
.args(args)
.current_dir(cwd)
.envs(env)
+2
Переглянути файл
@@ -287,6 +287,8 @@ mod tests {
detected: true,
is_test: true,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
+88 -20
Переглянути файл
@@ -14,10 +14,12 @@ use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::process::{Child, Command};
pub const PORT_PLACEHOLDER: &str = "${port}";
/// Replaced by the session's working directory (the project) in adapter arguments.
pub const ROOT_PLACEHOLDER: &str = "${root}";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Transport {
@@ -36,6 +38,11 @@ pub struct AdapterSpec {
pub cwd: PathBuf,
/// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`).
pub env: Vec<(String, String)>,
/// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface.
pub server_host: Option<String>,
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -45,6 +52,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
transport: transport.to_owned(),
connect: None,
install_hint: Some(hint.to_owned()),
};
Some(match adapter {
@@ -116,6 +124,8 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
install_hint: config.install_hint.clone(),
cwd,
env: Vec::new(),
server_host: None,
connect: config.connect.clone(),
}
}
@@ -124,6 +134,19 @@ pub fn substitute_port(args: &[String], port: u16) -> Vec<String> {
args.iter().map(|a| a.replace(PORT_PLACEHOLDER, &port.to_string())).collect()
}
/// Replaces every `${root}` occurrence with `root`.
pub fn substitute_root(args: &[String], root: &Path) -> Vec<String> {
let root = root.to_string_lossy();
args.iter().map(|a| a.replace(ROOT_PLACEHOLDER, &root)).collect()
}
/// Splits `host:port` (`[v6]:port`).
pub fn parse_address(addr: &str) -> Option<(String, u16)> {
let (host, port) = addr.trim().rsplit_once(':')?;
let host = host.trim_start_matches('[').trim_end_matches(']');
Some((host.to_owned(), port.parse().ok().filter(|p| *p != 0)?))
}
/// A port that was free a moment ago on the loopback interface.
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
@@ -233,7 +256,9 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
let Some(python) = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd)).await.ok().flatten() else {
return Ok(()); // Reported as a missing adapter when it is started.
};
let probe = Command::new(&python)
let mut probe = Command::new(&python);
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.args(["-c", "import debugpy.adapter"])
.current_dir(&spec.cwd)
.stdin(Stdio::null())
@@ -301,13 +326,22 @@ pub trait AdapterLauncher: Send + Sync + 'static {
pub struct ProcessLauncher;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// How long an adapter inside another program (a headless Godot editor) may take to listen.
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
Box::pin(async move {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
if let Some(port) = reuse_port {
let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?;
// A remote adapter (`dlv --headless` on another machine) may answer slowly.
let (host, timeout) = match spec.server_host.as_deref() {
Some(host) => (host, CONNECT_TIMEOUT),
None => (LOOPBACK, Duration::from_secs(5)),
};
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
reader: Box::new(r),
@@ -317,6 +351,28 @@ impl AdapterLauncher for ProcessLauncher {
stderr,
});
}
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
Some(addr) => Some(
parse_address(addr)
.ok_or_else(|| IdeError::invalid(format!("invalid debug adapter address `{addr}`")))?,
),
None => None,
};
// An adapter inside a program the user runs (the Godot editor): use it, never stop it.
if let Some((host, port)) = &embedded
&& let Ok(Ok(stream)) =
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host.as_str(), *port))).await
{
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: None,
port: Some(*port),
stderr,
});
}
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
@@ -327,14 +383,20 @@ impl AdapterLauncher for ProcessLauncher {
.flatten()
.ok_or_else(|| missing(&spec))?;
let port = match spec.transport {
Transport::Tcp => Some(free_port()?),
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&spec.args, port),
None => spec.args.clone(),
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
@@ -363,7 +425,8 @@ impl AdapterLauncher for ProcessLauncher {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone());
}
let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await {
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
@@ -394,27 +457,32 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
});
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
push_line(&sink, line);
}));
}
/// Appends to the recent-output buffer of an adapter (the last 40 lines).
pub(crate) fn push_line(sink: &parking_lot::Mutex<Vec<String>>, line: String) {
let mut buf = sink.lock();
if buf.len() >= 40 {
buf.remove(0);
}
buf.push(line);
}
const LOOPBACK: &str = "127.0.0.1";
async fn connect_with_retries(
host: &str,
port: u16,
mut child: Option<&mut Child>,
timeout: Duration,
) -> IdeResult<tokio::net::TcpStream> {
let deadline = Instant::now() + timeout;
loop {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
match tokio::net::TcpStream::connect((host, port)).await {
Ok(s) => {
let _ = s.set_nodelay(true);
return Ok(s);
@@ -429,7 +497,7 @@ async fn connect_with_retries(
}
if Instant::now() >= deadline {
return Err(IdeError::unavailable(format!(
"could not connect to debug adapter on port {port}: {e}"
"could not connect to debug adapter at {host}:{port}: {e}"
)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
+4 -2
Переглянути файл
@@ -170,7 +170,7 @@ pub fn is_go_binary(exe: &str) -> bool {
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
use std::collections::HashMap;
let ps = |fields: &str| -> IdeResult<String> {
let out = std::process::Command::new("ps")
let out = ide_core::process::prepare(&mut std::process::Command::new("ps"))
.args(["-x", "-ww", "-o", fields])
.output()
.map_err(|e| IdeError::unavailable(format!("cannot list processes: {e}")))?;
@@ -212,7 +212,9 @@ pub const NODE_INSPECTOR_PORT: u16 = 9229;
/// picker does) and waits until it listens. Returns the inspector port.
#[cfg(unix)]
pub async fn enable_node_inspector(pid: u32) -> IdeResult<u16> {
let status = tokio::process::Command::new("kill")
let mut kill = tokio::process::Command::new("kill");
ide_core::process::prepare(kill.as_std_mut());
let status = kill
.args(["-USR1", &pid.to_string()])
.status()
.await
+1
Переглянути файл
@@ -55,6 +55,7 @@ pub async fn build(
) -> IdeResult<PathBuf> {
let cargo = find_binary("cargo", cwd).ok_or_else(|| IdeError::unavailable("`cargo` not found on PATH"))?;
let mut cmd = tokio::process::Command::new(cargo);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(args)
.arg("--message-format=json-render-diagnostics")
.current_dir(cwd)
+21 -2
Переглянути файл
@@ -95,7 +95,21 @@ pub struct Connection {
impl Connection {
/// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime.
pub fn start<R, W>(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
pub fn start<R, W>(reader: R, writer: W) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_output(reader, writer, Box::new(|_| {}))
}
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
@@ -133,9 +147,14 @@ impl Connection {
let reader_inner = inner.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let mut noise = |line: &[u8]| {
let text = ide_jsonrpc::output::decode(line);
tracing::warn!("dap: output outside the protocol: {text}");
on_output(text);
};
loop {
let msg = tokio::select! {
msg = framing::read_message(&mut reader) => msg,
msg = framing::read_message_or_noise(&mut reader, &mut noise) => msg,
_ = reader_inner.cancel.cancelled() => break,
};
match msg {
+97
Переглянути файл
@@ -0,0 +1,97 @@
//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens,
//! the remote JVM (`-agentlib:jdwp=…,server=n,address=<ide-host>:<port>`) connects to
//! it. java-debug can only attach, so the connection is relayed: once the JVM has
//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP
//! stream (handshake included) both ways.
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::ErrorKind;
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
/// How long the relay waits for java-debug to attach once the JVM is connected.
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
/// The address to listen on for `host`: the interface it names, or every interface when
/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to).
async fn bind(host: &str, port: u16) -> IdeResult<TcpListener> {
let host = host.trim();
let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') };
match TcpListener::bind((host, port)).await {
Ok(l) => Ok(l),
Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => {
TcpListener::bind(("0.0.0.0", port))
.await
.map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}")))
}
Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))),
}
}
/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays
/// to it (accepts one connection). Cancelled through `cancel` while waiting.
pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult<u16> {
let listener = bind(host, port).await?;
let jvm = tokio::select! {
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
accepted = listener.accept() => accepted.map_err(IdeError::from)?.0,
};
drop(listener);
let _ = jvm.set_nodelay(true);
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = relay.local_addr()?.port();
tokio::spawn(relay_one(relay, jvm));
Ok(local)
}
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
Ok(Ok((s, _))) => s,
_ => return,
};
drop(relay);
let _ = debugger.set_nodelay(true);
let mut debugger = debugger;
if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await {
tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn relays_the_jdwp_handshake_both_ways() {
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
// The remote JVM (`server=n`) connects to the IDE.
let mut jvm = loop {
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
break s;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let local = waiting.await.unwrap().unwrap();
// java-debug attaches to the relay and starts the handshake.
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
debugger.write_all(b"JDWP-Handshake").await.unwrap();
let mut buf = [0u8; 14];
jvm.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"JDWP-Handshake");
jvm.write_all(b"JDWP-Handshake").await.unwrap();
debugger.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"JDWP-Handshake");
}
#[tokio::test]
async fn waiting_is_cancellable() {
let cancel = CancellationToken::new();
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone()));
tokio::time::sleep(Duration::from_millis(50)).await;
cancel.cancel();
let err = waiting.await.unwrap().unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
}
+69 -17
Переглянути файл
@@ -12,6 +12,7 @@ pub mod adapter;
pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -45,7 +46,8 @@ pub struct LaunchConfig {
/// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
/// and launches the produced executable; `"debugServer": <port>` connects to an
/// adapter already listening on that local port (e.g. java-debug inside jdtls)
/// instead of starting one.
/// instead of starting one, `"debugServer": "host:port"` to one on another machine
/// (`dlv --headless`, `debugpy --listen`).
pub extra_json: Option<String>,
}
@@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() ->
})
}
/// Where a running adapter listens: `debugServer` of the launch arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugServer {
/// `None` for the loopback interface (an adapter inside a local process such as jdtls).
pub host: Option<String>,
pub port: u16,
}
/// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the
/// launch arguments and returns its port.
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<u16>> {
/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` /
/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`).
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<DebugServer>> {
let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) };
let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()));
match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
Some(p) => Ok(Some(p)),
None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))),
let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}"));
let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0);
if let Some(p) = v.as_u64() {
return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid);
}
let s = v.as_str().map(str::trim).ok_or_else(invalid)?;
let (host, port) = match s.rsplit_once(':') {
Some((host, port)) => {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
(Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port)
}
None => (None, s),
};
let port = port.trim().parse::<u64>().ok().and_then(port_of).ok_or_else(invalid)?;
Ok(Some(DebugServer { host, port }))
}
/// DAP stack frame → [`StackFrame`] (1-based → 0-based).
@@ -522,6 +543,17 @@ impl DebugManager {
/// waits for `initialized`, sends breakpoints, then `configurationDone`.
/// Events of the session (including cargo build output) go to `owner` only.
pub async fn start(&self, config: LaunchConfig, owner: SessionOwner) -> IdeResult<DebugSessionId> {
self.start_with(config, owner, |_| None).await
}
/// [`start`](Self::start) with the adapter executable chosen by `custom_command`
/// (the configured command → a user-chosen executable, from settings).
pub async fn start_with(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
let cwd = config
.cwd
@@ -531,6 +563,9 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
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).
if config.adapter == "debugpy" {
let python = match arguments.get("python") {
@@ -544,6 +579,9 @@ impl DebugManager {
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -582,11 +620,19 @@ impl DebugManager {
if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() {
arguments["__workspaceFolder"] = json!(cwd);
}
// Godot compares `project` with its own (resolved) project path: `/tmp/game` is
// rejected by an editor that opened `/private/tmp/game`.
if config.adapter == "godot"
&& let Some(project) = arguments.get("project").and_then(Value::as_str)
&& let Ok(real) = std::fs::canonicalize(project)
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server)
.await
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
}
/// `startDebugging` reverse request: a child session on the same adapter.
@@ -635,7 +681,10 @@ impl DebugManager {
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = Connection::start(io.reader, io.writer);
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
self.sessions.lock().insert(id, session.clone());
@@ -946,8 +995,8 @@ impl DebugManager {
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let body = self
.session(s)?
let session = self.session(s)?;
let body = session
.conn
.request_timeout(
"stackTrace",
@@ -955,11 +1004,14 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
Ok(body
.get("stackFrames")
.and_then(Value::as_array)
.map(|f| f.iter().map(frame_from).collect())
.unwrap_or_default())
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
}
pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
+40 -3
Переглянути файл
@@ -51,9 +51,16 @@ pub(crate) struct Session {
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
pub breakpoint_ids: Mutex<HashMap<i64, (PathBuf, usize)>>,
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
}
/// Adapters that only match files by their resolved path: Godot maps a script to
/// `res://` by prefix and silently never stops at a breakpoint set through a
/// symlinked path (`/tmp/game` while the editor has `/private/tmp/game`).
const RESOLVES_PATHS: &[&str] = &["godot"];
impl Session {
#[allow(clippy::too_many_arguments)]
pub fn new(
@@ -86,6 +93,7 @@ impl Session {
terminated: AtomicBool::new(false),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
}
}
@@ -94,6 +102,25 @@ impl Session {
self.owner.sink.emit(IdeEvent::Debug { session: self.id, event });
}
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
pub fn adapter_path(&self, path: &Path) -> PathBuf {
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
return path.to_path_buf();
}
match std::fs::canonicalize(path) {
Ok(real) if real != path => {
self.resolved_paths.lock().insert(real.clone(), path.to_path_buf());
real
}
_ => path.to_path_buf(),
}
}
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
self.resolved_paths.lock().get(Path::new(path)).cloned()
}
pub fn supports(&self, cap: &str) -> bool {
self.caps.lock().get(cap).and_then(Value::as_bool).unwrap_or(false)
}
@@ -135,7 +162,7 @@ impl Session {
.request_timeout(
"setBreakpoints",
json!({
"source": { "path": path, "name": name },
"source": { "path": self.adapter_path(path), "name": name },
"breakpoints": source_bps,
"sourceModified": false,
}),
@@ -311,7 +338,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body:
s.emit(DebugEvent::BreakpointsChanged { path: path.into() });
}
}
"exited" => s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }),
// Godot repeats `exited` / `terminated` when the client disconnects afterwards.
"exited" if !s.terminated.load(Ordering::Acquire) => {
s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 })
}
"exited" => {}
"terminated" => {
if !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(DebugEvent::Terminated);
@@ -390,7 +421,13 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
return Err("runInTerminal without args".into());
};
let mut cmd = tokio::process::Command::new(program);
cmd.args(rest).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
+180 -2
Переглянути файл
@@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() {
#[test]
fn debug_server_is_taken_from_launch_arguments() {
let local = |port| Some(DebugServer { host: None, port });
let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port });
let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"});
assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711));
assert_eq!(take_debug_server(&mut args).unwrap(), local(4711));
assert!(args.get("debugServer").is_none());
assert_eq!(args["mainClass"], "a.B");
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005));
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005));
assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None);
// A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`.
assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345));
assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678));
assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345));
assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345));
assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err());
}
#[test]
@@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
async fn connects_to_remote_debug_server_by_host() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let config = LaunchConfig {
name: "Remote".into(),
adapter: "delve".into(),
request: "attach".into(),
program: None,
args: vec![],
cwd: Some(std::env::temp_dir().into()),
env: BTreeMap::new(),
extra_json: Some(
json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]})
.to_string(),
),
};
let id = manager.start(config, owner).await.unwrap();
let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap();
assert_eq!(attach["arguments"]["mode"], "remote");
assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app");
assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client");
manager.stop(id).await.unwrap();
}
fn requests(h: &Harness, command: &str) -> Vec<Value> {
h.log
.lock()
@@ -837,3 +881,137 @@ async fn hot_swap_only_for_java_debug() {
let failed = hot_swap_result(&json!({"changedClasses": [], "errorMessage": "boom"}));
assert_eq!(failed.error_message.as_deref(), Some("boom"));
}
fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegistry> {
let lang = ide_language::LanguageConfig {
id: ide_core::LanguageId::new("gdscript"),
name: "GDScript".into(),
extensions: vec!["gd".into()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
transport: "tcp".into(),
connect: Some(format!("127.0.0.1:{port}")),
install_hint: Some("Open the project in the Godot editor".into()),
}),
};
Arc::new(LanguageRegistry::from_configs(vec![lang]))
}
fn godot_launch(project: &std::path::Path) -> LaunchConfig {
LaunchConfig {
name: "Main Scene".into(),
adapter: "godot".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(project.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(json!({"project": project, "scene": "main"}).to_string()),
}
}
/// Godot's debug adapter runs inside the editor (port 6006): an open editor is
/// used as is, nothing is started.
#[tokio::test]
async fn godot_connects_to_the_editor_adapter() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
let project = std::env::temp_dir();
let id = manager.start(godot_launch(&project), owner).await.unwrap();
let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap();
assert_eq!(launch["arguments"]["scene"], "main");
assert_eq!(launch["arguments"]["project"], json!(std::fs::canonicalize(&project).unwrap()));
assert!(log.lock().iter().any(|m| m["command"] == "configurationDone"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn godot_without_editor_or_executable_says_what_to_do() {
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("Open the project in the Godot editor"), "{}", err.message);
}
/// No editor open: the command (a headless editor) is started for the project and
/// the client connects once it listens; the started process ends with the session.
#[cfg(unix)]
#[tokio::test]
async fn godot_starts_a_headless_editor_when_none_is_open() {
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
// `sleep` stands in for `godot --headless --editor --path ${root} …`.
let manager = DebugManager::new(godot_registry(port, "sleep", &["30"]));
let id = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap();
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
manager.stop(id).await.unwrap();
}
/// Godot never stops at a breakpoint given through a symlinked path (it maps scripts
/// to `res://` by its resolved project path): breakpoints and `project` go out resolved.
#[cfg(unix)]
#[tokio::test]
async fn godot_gets_resolved_paths() {
let real = std::env::temp_dir().join(format!("openidea-godot-real-{}", std::process::id()));
let link = std::env::temp_dir().join(format!("openidea-godot-link-{}", std::process::id()));
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("player.gd"), "extends Node\n").unwrap();
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&real, &link).unwrap();
let real = std::fs::canonicalize(&real).unwrap();
let log: Log = Arc::default();
let (owner, _events) = recording_owner("main");
let manager = DebugManager::with_launcher(
godot_registry(1, "godot", &[]),
Arc::new(FakeLauncher { log: log.clone(), script: Script::default() }),
);
let bp = Breakpoint { line: 0, condition: None, hit_condition: None, log_message: None, enabled: true };
manager.set_breakpoints(link.join("player.gd"), vec![bp]).await.unwrap();
let id = manager.start(godot_launch(&link), owner).await.unwrap();
let sent = log.lock().iter().find(|m| m["command"] == "setBreakpoints").cloned().unwrap();
assert_eq!(sent["arguments"]["source"]["path"], json!(real.join("player.gd")));
let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap();
assert_eq!(launch["arguments"]["project"], json!(real));
manager.stop(id).await.unwrap();
std::fs::remove_file(&link).ok();
std::fs::remove_dir_all(&real).ok();
}
#[test]
fn root_and_address_placeholders() {
let args: Vec<String> = ["--path", "${root}", "--dap-port", "${port}"].iter().map(|s| s.to_string()).collect();
let args = adapter::substitute_port(&adapter::substitute_root(&args, std::path::Path::new("/g")), 6010);
assert_eq!(args, ["--path", "/g", "--dap-port", "6010"]);
assert_eq!(adapter::parse_address("127.0.0.1:6006"), Some(("127.0.0.1".into(), 6006)));
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
assert_eq!(adapter::parse_address("6006"), None);
}
+157
Переглянути файл
@@ -0,0 +1,157 @@
//! Drives Godot's debug adapter (inside the Godot editor) with the GDScript
//! language definition: a headless editor is started for the project, the main
//! scene is played, a breakpoint hits, stack / variables / stepping work, and the
//! game and the editor end with the session.
//!
//! Ignored by default (needs Godot 4). Run with
//! `GODOT_BIN=<godot executable> cargo test -p ide-dap --test godot -- --ignored --nocapture`.
use ide_core::event::DebugEvent;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::{LanguageConfig, LanguageRegistry};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "extends Node
var health: int = 3
func _ready() -> void:
\tvar total := add(2, 3)
\tprint(\"total = \", total)
\thealth -= 1
\tget_tree().quit()
func add(a: int, b: int) -> int:
\treturn a + b
";
/// 0-based line of `return a + b`.
const BREAK_LINE: u32 = 11;
fn project(dir: &Path) {
std::fs::write(
dir.join("project.godot"),
"config_version=5\n\n[application]\nconfig/name=\"Probe\"\nrun/main_scene=\"res://main.tscn\"\n",
)
.unwrap();
std::fs::write(
dir.join("main.tscn"),
"[gd_scene load_steps=2 format=3]\n\n[ext_resource type=\"Script\" path=\"res://player.gd\" id=\"1\"]\n\n[node name=\"Main\" type=\"Node\"]\nscript = ExtResource(\"1\")\n",
)
.unwrap();
std::fs::write(dir.join("player.gd"), SCRIPT).unwrap();
}
/// The built-in GDScript definition, with the editor's usual port moved aside so an
/// open editor can't answer instead of the one this test starts.
fn registry() -> Arc<LanguageRegistry> {
let builtin = LanguageRegistry::load(None).unwrap();
let mut gd: LanguageConfig = builtin.get(&ide_core::LanguageId::new("gdscript")).unwrap();
let dbg = gd.debugger.as_mut().unwrap();
let unused = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
dbg.connect = Some(format!("127.0.0.1:{unused}"));
Arc::new(LanguageRegistry::from_configs(vec![gd]))
}
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn godot_processes(project: &Path) -> usize {
let out = std::process::Command::new("pgrep").args(["-f", &project.to_string_lossy()]).output().unwrap();
String::from_utf8_lossy(&out.stdout).lines().count()
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn breakpoints_stepping_and_variables_in_a_real_godot() {
let Ok(godot) = std::env::var("GODOT_BIN") else {
eprintln!("GODOT_BIN not set; skipping");
return;
};
let dir = std::env::temp_dir().join(format!("openidea-godot-dap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let root = std::fs::canonicalize(&dir).unwrap();
project(&root);
let manager = DebugManager::new(registry());
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let script_as_opened = dir.join("player.gd");
manager
.set_breakpoints(
script_as_opened.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
// As the UI builds it for "Godot Project" (`godot --path .`).
let config = LaunchConfig {
name: "Godot Project".into(),
adapter: "godot".into(),
request: "launch".into(),
program: None,
args: vec![],
// The project as opened, through a symlink on macOS (`/var` → `/private/var`).
cwd: Some(dir.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "project": dir, "scene": "main" }).to_string()),
};
let id = manager
.start_with(config, SessionOwner::new("main", sink), |_| Some(godot.clone()))
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
let stopped = wait_for(&events, Duration::from_secs(120), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].name, "add");
assert_eq!(frames[0].line, BREAK_LINE);
// Frames name the file as the IDE opened it, not Godot's resolved path.
assert_eq!(frames[0].path.as_deref().map(Path::new), Some(script_as_opened.as_path()));
assert_eq!(frames[1].name, "_ready");
let scopes = manager.scopes(id, frames[0].id).await.unwrap();
let locals = scopes.iter().find(|s| s.name == "Locals").expect("Locals scope");
let vars = manager.variables(id, locals.variables_reference).await.unwrap();
let value = |n: &str| vars.iter().find(|v| v.name == n).map(|v| v.value.clone());
assert_eq!((value("a").as_deref(), value("b").as_deref()), (Some("2"), Some("3")), "{vars:?}");
events.lock().clear();
manager.step(id, thread_id, StepKind::Over).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!((frames[0].name.as_str(), frames[0].line), ("_ready", 6), "stepped back into `_ready`, at the print");
manager.resume(id, thread_id).await.unwrap();
// (The game's last output is not always forwarded when it quits right away.)
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Terminated)).await;
let _ = manager.stop(id).await;
// The headless editor started for the session ends with it, the game too.
let start = Instant::now();
while godot_processes(&root) > 0 {
assert!(start.elapsed() < Duration::from_secs(10), "Godot still running for {}", root.display());
tokio::time::sleep(Duration::from_millis(200)).await;
}
std::fs::remove_dir_all(&root).ok();
}
+1 -2
Переглянути файл
@@ -17,8 +17,7 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
dirs.workspace = true
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
russh-config = "0.58.0"
ide-ssh.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
+235 -19
Переглянути файл
@@ -15,7 +15,8 @@ use sqlx::{
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -39,6 +40,8 @@ pub struct QueryResult {
/// More rows exist beyond the limit.
pub has_more: bool,
pub elapsed_ms: f64,
/// Until the server's first answer; the rest of `elapsed_ms` is fetching.
pub execution_ms: f64,
}
pub enum Pool {
@@ -129,7 +132,9 @@ macro_rules! fetch {
let mut affected: Option<u64> = None;
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = None;
while let Some(item) = stream.next().await {
first.get_or_insert_with(Instant::now);
match item {
Ok(sqlx::Either::Left(done)) => affected = Some(affected.unwrap_or(0) + done.rows_affected()),
Ok(sqlx::Either::Right(row)) => {
@@ -151,12 +156,13 @@ macro_rules! fetch {
drop(stream);
match failure {
Some(e) => Err(e),
None => Ok((columns, rows, affected, has_more)),
None => Ok((columns, rows, affected, has_more, first)),
}
}};
}
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool);
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
/// The server-side id of a connection (to cancel its statement from another one).
macro_rules! backend_id {
@@ -184,6 +190,90 @@ fn looks_like_query(sql: &str) -> bool {
)
}
fn file_setting(path: &str) -> Option<PathBuf> {
let path = path.trim();
(!path.is_empty()).then(|| ide_ssh::expand(path))
}
fn is_identifier(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
if keyword || v.parse::<f64>().is_ok() {
v.to_owned()
} else {
format!("'{}'", v.replace('\\', "\\\\").replace('\'', "''"))
}
}
/// How the Options and Advanced tabs reach a connection.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Setup {
/// PostgreSQL run-time parameters (startup `options`), SQLite pragmas.
pub params: Vec<(String, String)>,
/// Run on every new connection: MySQL session settings, then the startup script.
pub statements: Vec<String>,
pub application_name: Option<String>,
pub charset: Option<String>,
pub collation: Option<String>,
pub statement_cache: Option<usize>,
pub connect_timeout: Option<Duration>,
}
impl Setup {
pub fn of(source: &DataSource) -> IdeResult<Self> {
let mut s = Setup::default();
let o = &source.options;
match source.driver {
DbDriver::Postgres => {
if source.read_only {
s.params.push(("default_transaction_read_only".into(), "on".into()));
}
if !o.default_schema.trim().is_empty() {
s.params.push(("search_path".into(), o.default_schema.trim().into()));
}
if !o.time_zone.trim().is_empty() {
s.params.push(("TimeZone".into(), o.time_zone.trim().into()));
}
}
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
_ => {}
}
for p in &source.properties {
let value = p.value.trim();
let name = p.name.trim().replace('-', "_");
if name.is_empty() {
continue;
}
if !is_identifier(&name) {
return Err(IdeError::invalid(format!("invalid driver property name: {}", p.name.trim())));
}
let number = |what: &str| {
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
};
match (source.driver, name.to_ascii_lowercase().as_str()) {
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
_ => s.params.push((name, value.to_owned())),
}
}
let script = o.startup_script.trim();
if !script.is_empty() {
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
}
Ok(s)
}
}
impl Pool {
pub fn driver(&self) -> DbDriver {
match self {
@@ -196,7 +286,24 @@ impl Pool {
/// Opens a pool and one connection (so bad settings fail here).
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
const MAX: u32 = 4;
let timeout = Duration::from_secs(30);
let setup = Setup::of(source)?;
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
// Runs the session statements (startup script…) on every new connection.
macro_rules! after_connect {
() => {{
let init = init.clone();
move |conn, _| {
let init = init.clone();
Box::pin(async move {
for sql in init.iter() {
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *conn).await?;
}
Ok(())
})
}
}};
}
match source.driver {
DbDriver::Sqlite => {
let file = source.file.as_deref().ok_or_else(|| IdeError::invalid("no database file"))?;
@@ -204,11 +311,18 @@ impl Pool {
Some(r) if file.is_relative() => r.join(file),
_ => file.to_path_buf(),
};
let opts =
let mut opts =
SqliteConnectOptions::new().filename(&file).create_if_missing(false).read_only(source.read_only);
for (k, v) in setup.params {
opts = opts.pragma(k, v);
}
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let pool = SqlitePoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
@@ -219,24 +333,39 @@ impl Pool {
.host(&source.host)
.port(source.port.unwrap_or(5432))
.username(&source.user)
.application_name("OpenIDEA")
.application_name(setup.application_name.as_deref().unwrap_or("OpenIDEA"))
.ssl_mode(match source.ssl {
DbSslMode::Disable => PgSslMode::Disable,
DbSslMode::Prefer => PgSslMode::Prefer,
DbSslMode::Require => PgSslMode::Require,
DbSslMode::VerifyCa => PgSslMode::VerifyCa,
DbSslMode::VerifyFull => PgSslMode::VerifyFull,
});
if let Some(f) = file_setting(&source.ssl_ca_file) {
opts = opts.ssl_root_cert(f);
}
if let Some(f) = file_setting(&source.ssl_cert_file) {
opts = opts.ssl_client_cert(f);
}
if let Some(f) = file_setting(&source.ssl_key_file) {
opts = opts.ssl_client_key(f);
}
if !source.database.is_empty() {
opts = opts.database(&source.database);
}
if let Some(p) = password {
opts = opts.password(p);
}
if source.read_only {
opts = opts.options([("default_transaction_read_only", "on")]);
if !setup.params.is_empty() {
opts = opts.options(setup.params);
}
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let pool = PgPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
@@ -251,25 +380,41 @@ impl Pool {
DbSslMode::Disable => MySqlSslMode::Disabled,
DbSslMode::Prefer => MySqlSslMode::Preferred,
DbSslMode::Require => MySqlSslMode::Required,
DbSslMode::VerifyCa => MySqlSslMode::VerifyCa,
DbSslMode::VerifyFull => MySqlSslMode::VerifyIdentity,
});
if let Some(f) = file_setting(&source.ssl_ca_file) {
opts = opts.ssl_ca(f);
}
if let Some(f) = file_setting(&source.ssl_cert_file) {
opts = opts.ssl_client_cert(f);
}
if let Some(f) = file_setting(&source.ssl_key_file) {
opts = opts.ssl_client_key(f);
}
if !source.database.is_empty() {
opts = opts.database(&source.database);
}
if let Some(p) = password {
opts = opts.password(p);
}
let read_only = source.read_only;
if let Some(c) = &setup.charset {
opts = opts.charset(c);
}
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let tz = source.options.time_zone.trim();
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let pool = MySqlPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(move |conn, _| {
Box::pin(async move {
if read_only {
conn.execute("SET SESSION TRANSACTION READ ONLY").await?;
}
Ok(())
})
})
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
@@ -282,6 +427,10 @@ impl Pool {
with_pool!(self, p => p.close().await)
}
pub fn is_closed(&self) -> bool {
with_pool!(self, p => p.is_closed())
}
/// Runs `sql` (no row limit beyond `max`), for introspection.
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
@@ -408,14 +557,16 @@ impl Held {
}
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
};
let (columns, rows, affected, has_more) = fetched;
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(QueryResult {
statement: sql.to_owned(),
affected: if columns.is_empty() { Some(affected.unwrap_or(0) as f64) } else { None },
columns,
rows,
has_more,
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
elapsed_ms,
execution_ms: first.map_or(elapsed_ms, |t| t.duration_since(started).as_secs_f64() * 1000.0),
})
}
}
@@ -434,3 +585,68 @@ fn statement_columns<S: sqlx::Statement>(s: &S) -> Vec<ResultColumn> {
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
fn prop(name: &str, value: &str) -> DbProperty {
DbProperty { name: name.into(), value: value.into() }
}
#[test]
fn postgres_options_become_runtime_parameters() {
let s = DataSource {
driver: DbDriver::Postgres,
read_only: true,
options: DbOptions {
default_schema: "app, public".into(),
time_zone: "Europe/Paris".into(),
startup_script: "SET work_mem = '64MB'; select 1;".into(),
..Default::default()
},
properties: vec![prop("statement_timeout", "5s"), prop("application_name", "me"), prop("", "x")],
..Default::default()
};
let setup = Setup::of(&s).unwrap();
let names: Vec<_> = setup.params.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(names, ["default_transaction_read_only", "search_path", "TimeZone", "statement_timeout"]);
assert_eq!(setup.application_name.as_deref(), Some("me"));
assert_eq!(setup.statements, ["SET work_mem = '64MB'", "select 1"]);
}
#[test]
fn mysql_properties_become_session_variables() {
let s = DataSource {
driver: DbDriver::Mysql,
read_only: true,
properties: vec![
prop("sql_mode", "ANSI_QUOTES"),
prop("max_execution_time", "1000"),
prop("charset", "utf8mb4"),
prop("connect-timeout", "5"),
],
..Default::default()
};
let setup = Setup::of(&s).unwrap();
assert_eq!(
setup.statements,
[
"SET SESSION TRANSACTION READ ONLY",
"SET SESSION sql_mode = 'ANSI_QUOTES'",
"SET SESSION max_execution_time = 1000"
]
);
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
}
}
+137
Переглянути файл
@@ -0,0 +1,137 @@
//! Query history (IDEA's Browse Query History): the statements each data
//! source ran, newest first. One JSON file in the app's data directory for all
//! projects, keyed by project root, then data source id — never committed.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Statements kept per data source.
pub const MAX_ENTRIES: usize = 500;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbHistoryEntry")]
pub struct HistoryEntry {
pub text: String,
/// Milliseconds since the Unix epoch.
pub time: f64,
}
/// Project root → data source id → entries, newest first.
type Contents = BTreeMap<String, BTreeMap<String, Vec<HistoryEntry>>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct HistoryStore {
path: PathBuf,
project: String,
}
impl HistoryStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
Self { path, project: root.to_string_lossy().into_owned() }
}
pub fn list(&self, source: &str) -> Vec<HistoryEntry> {
let _g = LOCK.lock();
self.read().remove(&self.project).and_then(|mut p| p.remove(source)).unwrap_or_default()
}
/// Records `text`; running a statement again moves it to the top.
pub fn add(&self, source: &str, text: &str, time: f64) -> IdeResult<()> {
let text = text.trim();
if text.is_empty() {
return Ok(());
}
let _g = LOCK.lock();
let mut all = self.read();
let entries = all.entry(self.project.clone()).or_default().entry(source.to_owned()).or_default();
entries.retain(|e| e.text != text);
entries.insert(0, HistoryEntry { text: text.to_owned(), time });
entries.truncate(MAX_ENTRIES);
self.write(&all)
}
/// Removes the entries with these texts (all of them for `None`).
pub fn remove(&self, source: &str, texts: Option<&[String]>) -> IdeResult<()> {
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return Ok(()) };
match texts {
None => {
project.remove(source);
}
Some(texts) => {
if let Some(entries) = project.get_mut(source) {
entries.retain(|e| !texts.contains(&e.text));
}
}
}
self.write(&all)
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("query history: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("query history: {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 bytes = serde_json::to_vec(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(&tmp, bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_newest_first_without_duplicates_per_project_and_source() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("h.json");
let a = HistoryStore::new(file.clone(), Path::new("/p/a"));
let b = HistoryStore::new(file, Path::new("/p/b"));
a.add("s1", "select 1", 1.0).unwrap();
a.add("s1", " select 2 ", 2.0).unwrap();
a.add("s1", "select 1", 3.0).unwrap();
a.add("s2", "select 3", 4.0).unwrap();
b.add("s1", "select 4", 5.0).unwrap();
let texts = |v: Vec<HistoryEntry>| v.into_iter().map(|e| e.text).collect::<Vec<_>>();
assert_eq!(texts(a.list("s1")), ["select 1", "select 2"]);
assert_eq!(texts(a.list("s2")), ["select 3"]);
assert_eq!(texts(b.list("s1")), ["select 4"]);
a.remove("s1", Some(&["select 2".to_owned()])).unwrap();
assert_eq!(texts(a.list("s1")), ["select 1"]);
a.remove("s1", None).unwrap();
assert!(a.list("s1").is_empty());
assert_eq!(texts(b.list("s1")), ["select 4"]);
}
#[test]
fn caps_entries() {
let dir = tempfile::tempdir().unwrap();
let h = HistoryStore::new(dir.path().join("h.json"), Path::new("/p"));
for i in 0..MAX_ENTRIES + 5 {
h.add("s", &format!("select {i}"), i as f64).unwrap();
}
let list = h.list("s");
assert_eq!(list.len(), MAX_ENTRIES);
assert_eq!(list[0].text, format!("select {}", MAX_ENTRIES + 4));
}
}
+338 -73
Переглянути файл
@@ -3,17 +3,24 @@
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
pub mod conn;
pub mod history;
pub mod plan;
pub mod secrets;
pub mod source;
pub mod split;
pub mod sql;
pub mod tunnel;
pub use conn::{Held, Pool, QueryResult, ResultColumn};
pub use source::{DataSource, DbDriver, DbSsh, DbSshAuth, DbSslMode};
pub use history::HistoryEntry;
pub use plan::{PlanNode, PlanProperty};
pub use source::{
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
};
pub use sql::{CellValue, DbRowEdit, SortKey};
use history::HistoryStore;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use ide_ssh::{SshManager, SshTarget, Tunnel};
use parking_lot::Mutex;
use secrets::SecretStore;
use serde::{Deserialize, Serialize};
@@ -21,13 +28,74 @@ use specta::Type;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DbTableKind {
Table,
View,
MaterializedView,
}
impl DbTableKind {
fn from_catalog(kind: Option<&str>) -> Self {
match kind {
Some("view") => Self::View,
Some("materialized view") => Self::MaterializedView,
_ => Self::Table,
}
}
}
/// Schema objects other than tables and columns (Go to DDL, Drop).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DbObjectKind {
Function,
Procedure,
Trigger,
Sequence,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbTriggerInfo")]
pub struct TriggerInfo {
pub name: String,
/// `before`, `after`, `instead of`.
pub timing: String,
/// `insert`, `update or delete`…
pub events: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbRoutineInfo")]
pub struct RoutineInfo {
pub name: String,
/// `Function` or `Procedure`.
pub kind: DbObjectKind,
pub arguments: String,
/// Result type of a function.
pub returns: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSequenceInfo")]
pub struct SequenceInfo {
pub name: String,
pub data_type: String,
}
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
pub struct SchemaObjects {
pub routines: Vec<RoutineInfo>,
pub sequences: Vec<SequenceInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -65,6 +133,7 @@ pub struct TableDetails {
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKey>,
pub triggers: Vec<TriggerInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -140,11 +209,12 @@ pub struct DbManager {
tunnels: Mutex<HashMap<String, Arc<Tunnel>>>,
/// Serialises opening tunnels so concurrent connects share one.
tunnel_open: tokio::sync::Mutex<()>,
/// Host keys awaiting the user's trust, by data source id.
unknown_host_keys: Mutex<HashMap<String, UnknownHostKey>>,
known_hosts: PathBuf,
ssh_config: Option<PathBuf>,
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
/// Passwords entered this session (not saved, or not yet), by credential key.
session_passwords: Mutex<HashMap<String, String>>,
/// Manual-commit data sources, each with the connection of its open
@@ -210,15 +280,33 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) {
}
/// Says which leg failed when the database is unreachable from the SSH host.
fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError {
let Some(ssh) = source.ssh.as_ref().filter(|_| tunneled && e.kind == ErrorKind::Io) else { return e };
fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeError {
let Some(via) = via.filter(|_| e.kind == ErrorKind::Io) else { return e };
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
IdeError::new(
ErrorKind::Io,
format!("{} through the SSH tunnel ({}:{port} as seen from {})", e.message, source.host, ssh.host),
format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host),
)
}
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
let pool = Arc::downgrade(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
tick.tick().await;
loop {
tick.tick().await;
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
if let Err(e) = p.rows("SELECT 1", 1).await {
tracing::debug!("keep-alive: {}", e.message);
}
}
});
}
/// Rolls back a session's transaction and releases its connection.
async fn end_session(session: &Session) {
let held = session.lock().await.take();
@@ -230,28 +318,33 @@ async fn end_session(session: &Session) {
}
impl DbManager {
/// `passwords` is the app-wide file of saved passwords ([`SecretStore`]).
pub fn new(root: PathBuf, passwords: PathBuf) -> Self {
let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh");
Self::with_ssh_files(root, passwords, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config")))
}
/// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests).
pub fn with_ssh_files(
root: PathBuf,
passwords: PathBuf,
known_hosts: PathBuf,
ssh_config: Option<PathBuf>,
) -> Self {
/// `passwords` / `history` are the app-wide files of saved passwords
/// ([`SecretStore`]) and query history ([`HistoryStore`]).
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> Self {
let history = Arc::new(HistoryStore::new(history, &root));
let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>();
{
let history = history.clone();
let spawned = std::thread::Builder::new().name("openidea-db-history".into()).spawn(move || {
for (source, text, time) in statements {
if let Err(e) = history.add(&source, &text, time) {
tracing::warn!("{}", e.message);
}
}
});
if let Err(e) = spawned {
tracing::warn!("query history recorder: {e}");
}
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
history,
recorder,
root,
pools: Mutex::default(),
tunnels: Mutex::default(),
tunnel_open: tokio::sync::Mutex::new(()),
unknown_host_keys: Mutex::default(),
known_hosts,
ssh_config,
ssh,
session_passwords: Mutex::default(),
sessions: Mutex::default(),
}
@@ -261,14 +354,16 @@ impl DbManager {
source::load(&self.root)
}
/// The data source of connection target `target`, with its database for `<id>:<database>`.
/// The connection settings of target `target` ([`DataSource::resolved`]), with
/// its database for `<id>:<database>`.
pub fn source(&self, target: &str) -> IdeResult<DataSource> {
let (id, database) = split_target(target);
let mut source = self
.sources()?
.into_iter()
.find(|s| s.id == id)
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?;
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?
.resolved()?;
if let Some(db) = database {
source.database = db.to_owned();
}
@@ -303,7 +398,14 @@ impl DbManager {
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
/// A data source using an SSH configuration sets that configuration's secret.
pub async fn set_ssh_secret(&self, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
if let Some(c) = config.filter(|c| !c.trim().is_empty()) {
let save = self.ssh.config(&c).map(|c| c.save_secret).unwrap_or(save);
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(secrets::ssh_key(id), secret, save).await
}
@@ -341,15 +443,35 @@ impl DbManager {
}
async fn password(&self, source: &DataSource) -> Option<String> {
self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
}
/// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`).
pub async fn trust_ssh_host(&self, id: &str) -> IdeResult<()> {
let key =
self.unknown_host_keys.lock().remove(id).ok_or_else(|| IdeError::not_found("no host key to trust"))?;
let path = self.known_hosts.clone();
tokio::task::spawn_blocking(move || key.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))?
if self.ssh.trust_host(&self.host_key(id)).await.is_ok() {
return Ok(());
}
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
match config.filter(|c| !c.trim().is_empty()) {
Some(c) => self.ssh.trust_host(&c).await,
None => Err(IdeError::not_found("no host key to trust")),
}
}
/// Key of the unknown host keys of data source `id`'s own SSH settings.
fn host_key(&self, id: &str) -> String {
format!("db:{}:{id}", self.root.display())
}
/// How the SSH host of `ssh` is shown in errors.
fn ssh_label(&self, ssh: &DbSsh) -> String {
match self.ssh.config(ssh.config.trim()) {
Ok(c) if !ssh.config.trim().is_empty() => c.label(),
_ => ssh.host.clone(),
}
}
/// Opens the SSH tunnel of `source` when it has one, returning the settings to
@@ -373,35 +495,34 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
let config = ssh.config.trim();
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,
};
let target = SshTarget {
host: ssh.host.clone(),
port: ssh.port,
user: ssh.user.clone(),
auth: ssh.auth,
key_file: ssh.key_file.clone(),
};
Arc::new(self.ssh.connect(&self.host_key(&source.id), &target, secret.as_deref()).await?)
} else {
if let Some(s) = ssh_secret {
let save = self.ssh.config(config)?.save_secret;
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let spec = TunnelSpec {
ssh,
secret: secret.as_deref(),
target_host,
target_port,
known_hosts: &self.known_hosts,
ssh_config: self.ssh_config.as_deref(),
};
match Tunnel::open(spec).await {
Ok(t) => {
let t = Arc::new(t);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
Err(OpenError::Other(e)) => Err(e),
Err(OpenError::UnknownHostKey(k)) => {
let message = format!("Unknown SSH host key for {}:{} ({})", k.host, k.port, k.fingerprint());
self.unknown_host_keys.lock().insert(source.id.clone(), *k);
Err(IdeError::new(ErrorKind::Protocol, message))
}
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
@@ -411,14 +532,19 @@ impl DbManager {
password: Option<String>,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let source = &source.resolved()?;
let password = match password {
Some(p) => Some(p),
None => self.password(source).await,
};
let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?;
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root))
.await
.map_err(|e| through_tunnel(e, source, tunnel.is_some()))?;
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
through_tunnel(
e,
source,
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
)
})?;
let version = match source.driver {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres => "SELECT version()",
@@ -455,12 +581,21 @@ impl DbManager {
Ok(p) => Arc::new(p),
Err(e) => {
self.drop_unused_tunnel(source_id);
return Err(through_tunnel(e, &source, tunnel.is_some()));
return Err(through_tunnel(
e,
&source,
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
));
}
};
let mut pools = self.pools.lock();
// Another caller may have connected meanwhile; keep theirs.
Ok(pools.entry(id.to_owned()).or_insert(pool).clone())
let kept = self.pools.lock().entry(id.to_owned()).or_insert_with(|| pool.clone()).clone();
if Arc::ptr_eq(&kept, &pool)
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
{
keep_alive(&pool, Duration::from_secs(secs.into()));
}
Ok(kept)
}
/// Closes the tunnel of `source_id` once none of its targets is connected.
@@ -496,24 +631,30 @@ impl DbManager {
/// The databases of the server of data source `id` (PostgreSQL), the connected one first.
pub async fn databases(&self, id: &str) -> IdeResult<Databases> {
let pool = self.pool(split_target(id).0).await?;
let id = split_target(id).0;
let introspection = self.source(id)?.introspection;
let system = introspection.show_system;
let pool = self.pool(id).await?;
if pool.driver() != DbDriver::Postgres {
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
}
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
let rows = pool.rows(sql::DATABASES, MAX_META_ROWS).await?;
let rows = pool.rows(sql::databases(introspection.show_templates), MAX_META_ROWS).await?;
let mut names: Vec<String> =
first(rows).filter(|n| *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
if !names.contains(&current) {
names.insert(0, current.clone());
}
Ok(Databases { current, names })
}
/// Every schema (the Schemas tab picks the ones the tree shows); system schemas
/// only with [`DbIntrospection::show_system`].
pub async fn schemas(&self, id: &str) -> IdeResult<Vec<String>> {
let system = self.source(id)?.introspection.show_system;
let pool = self.pool(id).await?;
let rows = pool.rows(sql::schemas(pool.driver()), MAX_META_ROWS).await?;
let rows = pool.rows(sql::schemas(pool.driver(), system), MAX_META_ROWS).await?;
Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect())
}
@@ -525,9 +666,7 @@ impl DbManager {
.filter_map(|r| {
let mut it = r.into_iter();
let name = it.next().flatten()?;
let kind =
if it.next().flatten().as_deref() == Some("view") { DbTableKind::View } else { DbTableKind::Table };
Some(TableInfo { name, kind })
Some(TableInfo { name, kind: DbTableKind::from_catalog(it.next().flatten().as_deref()) })
})
.collect())
}
@@ -575,7 +714,85 @@ impl DbManager {
.collect();
}
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
Ok(TableDetails { columns, indexes, foreign_keys })
let triggers = pool
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
.await
.unwrap_or_else(|e| {
tracing::warn!("triggers: {}", e.message);
Vec::new()
})
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
let name = it.next().flatten()?;
let (timing, events) = if driver == DbDriver::Sqlite {
sql::sqlite_trigger_event(&it.next().flatten().unwrap_or_default())
} else {
(it.next().flatten().unwrap_or_default(), it.next().flatten().unwrap_or_default())
};
Some(TriggerInfo { name, timing, events })
})
.collect();
Ok(TableDetails { columns, indexes, foreign_keys, triggers })
}
/// The routines and sequences of `schema`.
pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
let pool = self.pool(id).await?;
let driver = pool.driver();
let mut out = SchemaObjects::default();
if let Some(q) = sql::routines(driver, schema) {
out.routines = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
let name = it.next().flatten()?;
let kind = if it.next().flatten().as_deref() == Some("procedure") {
DbObjectKind::Procedure
} else {
DbObjectKind::Function
};
let arguments = it.next().flatten().unwrap_or_default();
let returns = it.next().flatten().filter(|r| !r.is_empty() && kind == DbObjectKind::Function);
Some(RoutineInfo { name, kind, arguments, returns })
})
.collect();
}
if let Some(q) = sql::sequences(driver, schema) {
out.sequences = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
Some(SequenceInfo {
name: it.next().flatten()?,
data_type: it.next().flatten().unwrap_or_default(),
})
})
.collect();
}
Ok(out)
}
/// The definition of a routine (every overload), trigger or sequence.
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
let pool = self.pool(id).await?;
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
let defs: Vec<String> = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| r.into_iter().nth(column).flatten())
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
.collect();
if defs.is_empty() {
return Err(IdeError::not_found(format!("{schema}.{name}")));
}
Ok(defs.join("\n"))
}
/// The DDL of table or view `name`: SQLite's stored statements, MySQL's
@@ -671,7 +888,7 @@ impl DbManager {
_ => tables.push(CompletionTable {
schema,
name,
kind: if kind == "view" { DbTableKind::View } else { DbTableKind::Table },
kind: DbTableKind::from_catalog(Some(&kind)),
columns: vec![column],
}),
}
@@ -695,12 +912,60 @@ impl DbManager {
Ok(CompletionModel { default_schema, tables, foreign_keys })
}
/// Runs `statement` on `id` (in its open transaction in manual-commit mode),
/// recording it in the data source's query history.
pub async fn execute(
&self,
id: &str,
statement: &str,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
};
if ran {
self.remember_statement(id, statement);
}
r
}
fn remember_statement(&self, id: &str, statement: &str) {
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64() * 1000.0);
let _ = self.recorder.send((split_target(id).0.to_owned(), statement.to_owned(), time));
}
/// The query history of data source `id`, newest first.
pub async fn history(&self, id: &str) -> IdeResult<Vec<HistoryEntry>> {
let (history, source) = (self.history.clone(), split_target(id).0.to_owned());
tokio::task::spawn_blocking(move || history.list(&source)).await.map_err(|e| IdeError::internal(e.to_string()))
}
/// Forgets statements of the query history of `id` (all of them for `None`).
pub async fn remove_history(&self, id: &str, texts: Option<Vec<String>>) -> IdeResult<()> {
let (history, source) = (self.history.clone(), split_target(id).0.to_owned());
tokio::task::spawn_blocking(move || history.remove(&source, texts.as_deref()))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
async fn run(
&self,
id: &str,
statement: &str,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
+268
Переглянути файл
@@ -0,0 +1,268 @@
//! Execution plans (IDEA's Explain Plan): the `EXPLAIN` statement of each
//! dialect and its output read into one flat tree of operations.
use crate::DbDriver;
use crate::conn::QueryResult;
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use specta::Type;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPlanProperty")]
pub struct PlanProperty {
pub name: String,
pub value: String,
}
/// One operation of a plan; `parent` indexes the plan's node list (roots: `None`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPlanNode")]
pub struct PlanNode {
pub parent: Option<u32>,
/// `Full Scan`, `Index Scan`, `Hash Join`…
pub operation: String,
/// The object it works on and its key details (`table: o;`).
pub params: String,
pub properties: Vec<PlanProperty>,
}
/// The statement that explains `statement` without running it.
pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
let s = statement.trim().trim_end_matches(';');
match driver {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
}
}
/// Reads the result of [`explain_statement`].
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Postgres => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
let mut out = Vec::new();
for root in json.as_array().into_iter().flatten() {
if let Some(plan) = root.get("Plan") {
postgres(plan, None, &mut out);
}
}
Ok(out)
}
DbDriver::Mysql => {
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
}
}
fn prop(name: &str, value: impl Into<String>) -> PlanProperty {
PlanProperty { name: name.to_owned(), value: value.into() }
}
/// SQLite rows: `id, parent, notused, detail` (`SCAN o`, `SEARCH c USING INTEGER PRIMARY KEY (rowid=?)`).
fn sqlite(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = Vec::new();
let mut out = Vec::new();
for r in rows {
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
let (id, parent, detail) = (cell(0), cell(1), cell(3));
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
let (operation, params) = sqlite_operation(&detail);
out.push(PlanNode { parent, operation, params, properties: vec![prop("Detail", detail.clone())] });
ids.push(id);
}
out
}
fn sqlite_operation(detail: &str) -> (String, String) {
let words: Vec<&str> = detail.split_whitespace().collect();
let object = |i: usize| words.get(i).map(|t| format!("table: {t};")).unwrap_or_default();
let using = || detail.find(" USING ").map(|i| detail[i + 1..].to_owned()).unwrap_or_default();
match words.first().copied() {
Some("SCAN") if detail.contains(" USING ") => ("Index Scan".into(), format!("{} {}", object(1), using())),
Some("SCAN") => ("Full Scan".into(), object(1)),
Some("SEARCH") => ("Index Scan".into(), format!("{} {}", object(1), using()).trim().to_owned()),
Some("USE") if detail.contains("ORDER BY") => ("Order By".into(), String::new()),
Some("USE") if detail.contains("GROUP BY") => ("Group By".into(), String::new()),
Some("USE") if detail.contains("DISTINCT") => ("Distinct".into(), String::new()),
_ => (detail.to_owned(), String::new()),
}
}
/// PostgreSQL JSON plan node: `Node Type`, `Relation Name`, nested `Plans`.
fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
let text = |k: &str| node.get(k).and_then(Value::as_str).unwrap_or_default();
let mut operation = text("Node Type").to_owned();
if let Some(j) = node.get("Join Type").and_then(Value::as_str).filter(|j| *j != "Inner") {
operation = format!("{operation} ({j})");
}
let mut params = Vec::new();
if let Some(r) = node.get("Relation Name").and_then(Value::as_str) {
let alias = text("Alias");
params.push(if alias.is_empty() || alias == r {
format!("table: {r};")
} else {
format!("table: {r} {alias};")
});
}
if let Some(i) = node.get("Index Name").and_then(Value::as_str) {
params.push(format!("index: {i};"));
}
for k in ["Index Cond", "Hash Cond", "Merge Cond", "Filter", "Join Filter"] {
if let Some(c) = node.get(k).and_then(Value::as_str) {
params.push(format!("{}: {c};", k.to_lowercase()));
}
}
let properties = node
.as_object()
.into_iter()
.flatten()
.filter(|(k, _)| k.as_str() != "Plans")
.map(|(k, v)| {
let value = match v {
Value::String(s) => s.clone(),
Value::Array(a) => a
.iter()
.map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_owned))
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
};
prop(k, value)
})
.collect();
let me = out.len() as u32;
out.push(PlanNode { parent, operation, params: params.join(" "), properties });
for child in node.get("Plans").and_then(Value::as_array).into_iter().flatten() {
postgres(child, Some(me), out);
}
}
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], n: &str| {
names.iter().position(|c| c.eq_ignore_ascii_case(n)).and_then(|i| r.get(i).cloned().flatten())
};
rows.iter()
.map(|r| {
let access = col(r, "type").unwrap_or_default();
let operation = match access.as_str() {
"ALL" => "Full Scan".to_owned(),
"index" => "Full Index Scan".to_owned(),
"range" => "Index Range Scan".to_owned(),
"ref" | "eq_ref" | "ref_or_null" => "Index Lookup".to_owned(),
"const" | "system" => "Constant Lookup".to_owned(),
"" => col(r, "Extra").unwrap_or_else(|| "Select".to_owned()),
other => other.to_owned(),
};
let mut params = Vec::new();
if let Some(t) = col(r, "table") {
params.push(format!("table: {t};"));
}
if let Some(k) = col(r, "key") {
params.push(format!("key: {k};"));
}
let properties = names.iter().zip(r).filter_map(|(n, v)| v.as_ref().map(|v| prop(n, v.clone()))).collect();
PlanNode { parent: None, operation, params: params.join(" "), properties }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conn::ResultColumn;
fn result(columns: &[&str], rows: Vec<Vec<Option<&str>>>) -> QueryResult {
QueryResult {
statement: String::new(),
columns: columns.iter().map(|c| ResultColumn { name: (*c).into(), type_name: String::new() }).collect(),
rows: rows.into_iter().map(|r| r.into_iter().map(|v| v.map(str::to_owned)).collect()).collect(),
affected: None,
has_more: false,
elapsed_ms: 0.0,
execution_ms: 0.0,
}
}
#[test]
fn explains_per_dialect() {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
}
#[test]
fn reads_sqlite_plans_like_idea() {
let r = result(
&["id", "parent", "notused", "detail"],
vec![
vec![Some("3"), Some("0"), Some("0"), Some("SCAN o")],
vec![Some("5"), Some("0"), Some("0"), Some("SEARCH c USING INTEGER PRIMARY KEY (rowid=?)")],
vec![Some("9"), Some("0"), Some("0"), Some("USE TEMP B-TREE FOR ORDER BY")],
],
);
let plan = parse(DbDriver::Sqlite, &r).unwrap();
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
assert_eq!(
ops,
[
("Full Scan", "table: o;"),
("Index Scan", "table: c; USING INTEGER PRIMARY KEY (rowid=?)"),
("Order By", "")
]
);
assert!(plan.iter().all(|n| n.parent.is_none()));
}
#[test]
fn nests_sqlite_children_under_their_parent() {
let r = result(
&["id", "parent", "notused", "detail"],
vec![
vec![Some("2"), Some("0"), Some("0"), Some("CO-ROUTINE sub")],
vec![Some("4"), Some("2"), Some("0"), Some("SCAN t")],
],
);
let plan = parse(DbDriver::Sqlite, &r).unwrap();
assert_eq!(plan[1].parent, Some(0));
}
#[test]
fn reads_postgres_json_trees() {
let json = r#"[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Hash Cond": "(o.customer_id = c.id)",
"Total Cost": 12.5, "Plans": [
{"Node Type": "Seq Scan", "Relation Name": "orders", "Alias": "o", "Filter": "(status <> 'x')"},
{"Node Type": "Hash", "Plans": [{"Node Type": "Seq Scan", "Relation Name": "customers", "Alias": "c"}]}
]}}]"#;
let plan = parse(DbDriver::Postgres, &result(&["QUERY PLAN"], vec![vec![Some(json)]])).unwrap();
assert_eq!(plan.len(), 4);
assert_eq!(plan[0].operation, "Hash Join");
assert_eq!(plan[0].params, "hash cond: (o.customer_id = c.id);");
assert!(plan[0].properties.iter().any(|p| p.name == "Total Cost" && p.value == "12.5"));
assert_eq!((plan[1].parent, plan[1].params.as_str()), (Some(0), "table: orders o; filter: (status <> 'x');"));
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
}
#[test]
fn reads_mysql_rows() {
let r = result(
&["id", "select_type", "table", "type", "key", "rows", "Extra"],
vec![
vec![Some("1"), Some("SIMPLE"), Some("o"), Some("ALL"), None, Some("20"), Some("Using where")],
vec![Some("1"), Some("SIMPLE"), Some("c"), Some("eq_ref"), Some("PRIMARY"), Some("1"), None],
],
);
let plan = parse(DbDriver::Mysql, &r).unwrap();
assert_eq!((plan[0].operation.as_str(), plan[0].params.as_str()), ("Full Scan", "table: o;"));
assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;"));
assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20"));
}
}
+350 -7
Переглянути файл
@@ -35,25 +35,103 @@ pub enum DbSslMode {
#[default]
Prefer,
Require,
/// Require, and check the server certificate against the CA file.
VerifyCa,
/// [`DbSslMode::VerifyCa`], and check that the certificate names the host.
VerifyFull,
}
/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type").
/// IDEA's data source colours (tree node and console tab tint), e.g. red for production.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DbColor {
Red,
Orange,
Yellow,
Green,
Blue,
Violet,
}
/// Transaction control of the data source's consoles.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DbSshAuth {
Password,
/// A private key file, optionally protected by a passphrase.
KeyPair,
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
pub enum DbTxMode {
#[default]
OpenSsh,
Auto,
Manual,
}
/// Options tab: session settings applied to every connection.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct DbOptions {
pub transaction: DbTxMode,
/// PostgreSQL `search_path` (unqualified names resolve there).
pub default_schema: String,
/// Session time zone (`TimeZone` / `time_zone`); empty keeps the default.
pub time_zone: String,
/// Statements run on every new connection.
pub startup_script: String,
/// Run `SELECT 1` this often while connected.
pub keep_alive_secs: Option<u32>,
/// Disconnect after this long without statements (enforced by the UI, which sees the activity).
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct DbIntrospection {
pub all_databases: bool,
pub databases: Vec<String>,
pub all_schemas: bool,
pub schemas: Vec<String>,
/// `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…).
pub show_system: bool,
/// PostgreSQL template databases (`template0`, `template1`).
pub show_templates: bool,
/// Object filter: the tree lists only tables, views, routines and sequences matching
/// one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all.
pub object_filter: String,
}
impl Default for DbIntrospection {
fn default() -> Self {
Self {
all_databases: true,
databases: Vec::new(),
all_schemas: true,
schemas: Vec::new(),
show_system: false,
show_templates: false,
object_filter: String::new(),
}
}
}
/// Advanced tab: a driver property. PostgreSQL: run-time parameters (and
/// `application_name`); MySQL: session variables (and `charset`, `collation`);
/// SQLite: pragmas. See [`crate::conn`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct DbProperty {
pub name: String,
pub value: String,
}
/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations).
pub use ide_core::remote_settings::SshAuth as DbSshAuth;
/// Connect through an SSH tunnel: the database host and port are reached from `host`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)]
pub struct DbSsh {
pub enabled: bool,
/// A Settings › SSH Configurations id to connect with; empty = the fields below.
pub config: String,
/// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`].
pub host: String,
pub port: Option<u16>,
@@ -84,6 +162,20 @@ pub struct DataSource {
pub read_only: bool,
/// SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`).
pub ssh: Option<DbSsh>,
/// Wire-compatible product preset (`cockroachdb`, `tidb`…; empty: the driver itself).
pub vendor: String,
pub color: Option<DbColor>,
pub comment: String,
/// Connect with [`DataSource::url`] instead of the host / port / database fields.
pub url_only: bool,
pub url: String,
/// PEM files; `~/` is the home directory.
pub ssl_ca_file: String,
pub ssl_cert_file: String,
pub ssl_key_file: String,
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
}
impl Default for DataSource {
@@ -101,10 +193,188 @@ impl Default for DataSource {
save_password: false,
read_only: false,
ssh: None,
vendor: String::new(),
color: None,
comment: String::new(),
url_only: false,
url: String::new(),
ssl_ca_file: String::new(),
ssl_cert_file: String::new(),
ssl_key_file: String::new(),
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
}
}
}
/// The connection settings of a URL (`jdbc:` prefix allowed).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ParsedUrl {
pub driver: Option<DbDriver>,
pub host: String,
pub port: Option<u16>,
pub database: String,
pub user: String,
pub password: Option<String>,
/// Query parameters, in order.
pub params: Vec<(String, String)>,
/// SQLite file.
pub file: Option<String>,
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
let hex = |i: usize| b.get(i).and_then(|&c| (c as char).to_digit(16));
while i < b.len() {
if b[i] == b'%'
&& let (Some(h), Some(l)) = (hex(i + 1), hex(i + 2))
{
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The driver speaking the protocol of URL scheme `scheme` (wire-compatible products included).
pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
_ => None,
}
}
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
/// included, and `sqlite:path`.
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
let url = raw.trim();
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
let driver = scheme_driver(scheme).ok_or_else(bad)?;
if driver == DbDriver::Sqlite {
let file = rest.strip_prefix("//").unwrap_or(rest);
let file = file.split('?').next().unwrap_or_default();
if file.is_empty() {
return Err(bad());
}
return Ok(ParsedUrl { driver: Some(driver), file: Some(percent_decode(file)), ..Default::default() });
}
let rest = rest.strip_prefix("//").ok_or_else(bad)?;
let (main, query) = rest.split_once('?').unwrap_or((rest, ""));
let (authority, database) = main.split_once('/').unwrap_or((main, ""));
let (userinfo, hostport) = match authority.rsplit_once('@') {
Some((u, h)) => (Some(u), h),
None => (None, authority),
};
// Several hosts (failover lists): the first.
let hostport = hostport.split(',').next().unwrap_or_default();
let (host, port) = if let Some(v6) = hostport.strip_prefix('[') {
let (h, after) = v6.split_once(']').ok_or_else(bad)?;
(h.to_owned(), after.strip_prefix(':'))
} else {
match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_owned(), Some(p)),
None => (hostport.to_owned(), None),
}
};
let port = match port.filter(|p| !p.is_empty()) {
Some(p) => Some(p.parse::<u16>().map_err(|_| bad())?),
None => None,
};
let mut parsed = ParsedUrl {
driver: Some(driver),
host,
port,
database: percent_decode(database.trim_end_matches('/')),
..Default::default()
};
if let Some(u) = userinfo {
let (user, password) = match u.split_once(':') {
Some((u, p)) => (u, Some(percent_decode(p))),
None => (u, None),
};
parsed.user = percent_decode(user);
parsed.password = password;
}
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let (k, v) = (percent_decode(k), percent_decode(v));
match k.as_str() {
"user" if parsed.user.is_empty() => parsed.user = v,
"password" if parsed.password.is_none() => parsed.password = Some(v),
_ => parsed.params.push((k, v)),
}
}
Ok(parsed)
}
impl DataSource {
/// The settings to connect with: with [`DataSource::url_only`], host, port,
/// database, user, SSL and driver properties come from the URL (properties
/// of the Advanced tab still apply after the URL's).
pub fn resolved(&self) -> IdeResult<DataSource> {
if !self.url_only {
return Ok(self.clone());
}
let u = parse_url(&self.url)?;
let mut s = self.clone();
if let Some(d) = u.driver
&& d != s.driver
{
return Err(IdeError::invalid(format!("the URL is not for the {:?} driver", s.driver)));
}
if s.driver == DbDriver::Sqlite {
s.file = u.file.map(IpcPath::from);
return Ok(s);
}
s.host = u.host;
s.port = u.port;
s.database = u.database;
if !u.user.is_empty() {
s.user = u.user;
}
let mut properties = Vec::new();
for (k, v) in u.params {
match k.to_ascii_lowercase().as_str() {
"sslmode" | "ssl-mode" => {
s.ssl = match v.to_ascii_lowercase().replace('-', "_").as_str() {
"disable" | "disabled" => DbSslMode::Disable,
"require" | "required" => DbSslMode::Require,
"verify_ca" => DbSslMode::VerifyCa,
"verify_full" | "verify_identity" => DbSslMode::VerifyFull,
_ => DbSslMode::Prefer,
}
}
"sslrootcert" | "ssl-ca" | "sslca" => s.ssl_ca_file = v,
"sslcert" | "ssl-cert" => s.ssl_cert_file = v,
"sslkey" | "ssl-key" => s.ssl_key_file = v,
_ => properties.push(DbProperty { name: k, value: v }),
}
}
properties.append(&mut s.properties);
s.properties = properties;
Ok(s)
}
/// The password written in the URL of a [`DataSource::url_only`] data source.
pub fn url_password(&self) -> Option<String> {
if !self.url_only {
return None;
}
parse_url(&self.url).ok()?.password
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct SourcesFile {
#[serde(default, rename = "data-source")]
@@ -164,4 +434,77 @@ mod tests {
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
assert!(save(tmp.path(), &[DataSource::default()]).is_err());
}
#[test]
fn reads_configs_written_before_the_new_settings() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".openidea");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(FILE),
"[[data-source]]\nid = \"a\"\nname = \"old\"\ndriver = \"postgres\"\nhost = \"db\"\nssl = \"require\"\n",
)
.unwrap();
let s = load(tmp.path()).unwrap().remove(0);
assert_eq!(s.ssl, DbSslMode::Require);
assert!(s.introspection.all_databases && s.introspection.all_schemas);
assert_eq!((s.color, s.url_only, s.options.transaction), (None, false, DbTxMode::Auto));
}
#[test]
fn round_trips_the_new_settings() {
let tmp = tempfile::tempdir().unwrap();
let s = DataSource {
id: "b".into(),
name: "prod".into(),
driver: DbDriver::Mysql,
vendor: "tidb".into(),
color: Some(DbColor::Red),
comment: "careful".into(),
ssl: DbSslMode::VerifyFull,
ssl_ca_file: "~/ca.pem".into(),
options: DbOptions { time_zone: "UTC".into(), keep_alive_secs: Some(60), ..Default::default() },
introspection: DbIntrospection { all_schemas: false, schemas: vec!["app*".into()], ..Default::default() },
properties: vec![DbProperty { name: "sql_mode".into(), value: "ANSI".into() }],
..Default::default()
};
save(tmp.path(), std::slice::from_ref(&s)).unwrap();
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
}
#[test]
fn parses_urls() {
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
assert_eq!(u.driver, Some(DbDriver::Postgres));
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(5433), "shop"));
assert_eq!((u.user.as_str(), u.password.as_deref()), ("ann", Some("s@cret")));
assert_eq!(u.params, vec![("sslmode".into(), "require".into()), ("search_path".into(), "app".into())]);
let u = parse_url("mysql://[::1]/app?user=root").unwrap();
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
assert!(parse_url("oracle://x").is_err());
assert!(parse_url("postgres://h:99999/db").is_err());
}
#[test]
fn url_only_sources_connect_with_the_url() {
let s = DataSource {
driver: DbDriver::Postgres,
user: "fallback".into(),
url_only: true,
url: "postgres://ann:pw@h:6543/app?sslmode=verify-full&sslrootcert=/ca.pem&statement_timeout=5000".into(),
properties: vec![DbProperty { name: "work_mem".into(), value: "64MB".into() }],
..Default::default()
};
let r = s.resolved().unwrap();
assert_eq!((r.host.as_str(), r.port, r.database.as_str(), r.user.as_str()), ("h", Some(6543), "app", "ann"));
assert_eq!((r.ssl, r.ssl_ca_file.as_str()), (DbSslMode::VerifyFull, "/ca.pem"));
let names: Vec<_> = r.properties.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["statement_timeout", "work_mem"]);
assert_eq!(s.url_password().as_deref(), Some("pw"));
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
}
}
+165 -6
Переглянути файл
@@ -32,15 +32,26 @@ pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String {
}
}
/// Databases one can connect to (PostgreSQL).
pub const DATABASES: &str = "SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname";
/// PostgreSQL databases one can connect to; template databases only with `templates`.
pub fn databases(templates: bool) -> &'static str {
if templates {
"SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname"
} else {
"SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname"
}
}
/// Databases of managed services that their users cannot use (hidden unless connected to).
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
pub fn schemas(driver: DbDriver) -> &'static str {
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
DbDriver::Postgres if system => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -53,7 +64,7 @@ pub fn schemas(driver: DbDriver) -> &'static str {
}
}
/// Rows: `name, kind` where kind is `table` or `view`.
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
pub fn tables(driver: DbDriver, schema: &str) -> String {
match driver {
DbDriver::Sqlite => format!(
@@ -61,7 +72,13 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
quote_ident(driver, schema)
),
_ => format!(
DbDriver::Postgres => format!(
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = {} AND c.relkind IN ('r', 'v', 'm', 'p', 'f') ORDER BY 1",
quote_literal(driver, schema)
),
DbDriver::Mysql => format!(
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
@@ -134,7 +151,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY m.name, p.cid"
}
DbDriver::Postgres => {
"SELECT n.nspname, c.relname, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END, a.attname, \
"SELECT n.nspname, c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END, a.attname, \
format_type(a.atttypid, a.atttypmod), CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, \
CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary \
AND a.attnum = ANY(i.indkey)) THEN 1 ELSE 0 END, \
@@ -363,6 +380,123 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
)
}
/// Rows: `name, timing, events` of the triggers of a table (SQLite: `name, sql`).
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Sqlite => format!(
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
quote_ident(driver, schema)
),
DbDriver::Postgres => format!(
"SELECT t.tgname, CASE WHEN t.tgtype & 2 <> 0 THEN 'before' WHEN t.tgtype & 64 <> 0 THEN 'instead of' \
ELSE 'after' END, concat_ws(' or ', CASE WHEN t.tgtype & 4 <> 0 THEN 'insert' END, \
CASE WHEN t.tgtype & 16 <> 0 THEN 'update' END, CASE WHEN t.tgtype & 8 <> 0 THEN 'delete' END, \
CASE WHEN t.tgtype & 32 <> 0 THEN 'truncate' END) \
FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE NOT t.tgisinternal AND n.nspname = {s} AND c.relname = {t} ORDER BY 1"
),
DbDriver::Mysql => format!(
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
),
}
}
/// `before`, `insert` of SQLite's `CREATE TRIGGER name BEFORE INSERT ON …` (its catalog keeps only the text).
pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
let upper = sql.to_ascii_uppercase();
let words: Vec<&str> = upper.split_whitespace().collect();
let timing = if words.contains(&"BEFORE") {
"before"
} else if words.windows(2).any(|w| w == ["INSTEAD", "OF"]) {
"instead of"
} else {
"after"
};
let event = ["INSERT", "UPDATE", "DELETE"].into_iter().find(|e| words.contains(e)).unwrap_or_default();
(timing.to_owned(), event.to_ascii_lowercase())
}
/// Rows: `name, kind (function/procedure), arguments, result` of a schema's routines (none in SQLite).
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
let s = quote_literal(driver, schema);
match driver {
DbDriver::Sqlite => None,
// Functions of extensions are left out, as in IDEA.
DbDriver::Postgres => Some(format!(
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
pg_get_function_identity_arguments(p.oid), pg_get_function_result(p.oid) \
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = {s} AND p.prokind IN ('f', 'p') \
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') ORDER BY 1, 3"
)),
DbDriver::Mysql => Some(format!(
"SELECT r.routine_name, lower(r.routine_type), \
(SELECT GROUP_CONCAT(CONCAT(p.parameter_name, ' ', p.dtd_identifier) ORDER BY p.ordinal_position \
SEPARATOR ', ') FROM information_schema.parameters p WHERE p.specific_schema = r.routine_schema \
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
(driver == DbDriver::Postgres).then(|| {
format!(
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
quote_literal(driver, schema)
)
})
}
/// The statement reading the definition of a routine (a row per overload), trigger
/// or sequence, and the column that holds it.
pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, name: &str) -> Option<(String, usize)> {
use crate::DbObjectKind as K;
let (s, n) = (quote_literal(driver, schema), quote_literal(driver, name));
let target = qualified(driver, schema, name);
Some(match (driver, kind) {
(DbDriver::Sqlite, K::Trigger) => (
format!(
"SELECT sql FROM {}.sqlite_master WHERE type = 'trigger' AND name = {n}",
quote_ident(driver, schema)
),
0,
),
(DbDriver::Postgres, K::Function | K::Procedure) => (
format!(
"SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = {s} AND p.proname = {n} ORDER BY pg_get_function_identity_arguments(p.oid)"
),
0,
),
(DbDriver::Postgres, K::Trigger) => (
format!(
"SELECT pg_get_triggerdef(t.oid, true) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE NOT t.tgisinternal AND n.nspname = {s} AND t.tgname = {n}"
),
0,
),
(DbDriver::Postgres, K::Sequence) => (
format!(
"SELECT format(E'create sequence %s\\n as %s\\n increment by %s\\n minvalue %s\\n \
maxvalue %s\\n start with %s%s', {}, data_type, increment_by, min_value, max_value, start_value, \
CASE WHEN cycle THEN E'\\n cycle' ELSE '' END) \
FROM pg_sequences WHERE schemaname = {s} AND sequencename = {n}",
quote_literal(driver, &target)
),
0,
),
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
_ => return None,
})
}
/// A column of [`postgres_table_ddl`].
pub struct DdlColumn {
pub name: String,
@@ -452,6 +586,31 @@ mod tests {
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
assert_eq!(
ev("CREATE TRIGGER t BEFORE UPDATE OF a ON x BEGIN SELECT 1; END"),
("before".into(), "update".into())
);
assert_eq!(ev("create trigger t instead of insert on v begin end"), ("instead of".into(), "insert".into()));
assert_eq!(ev("CREATE TRIGGER t DELETE ON x BEGIN END"), ("after".into(), "delete".into()));
}
#[test]
fn schema_objects_per_dialect() {
use crate::DbObjectKind as K;
assert!(routines(DbDriver::Sqlite, "main").is_none());
assert!(sequences(DbDriver::Mysql, "db").is_none());
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
assert_eq!(
object_ddl(DbDriver::Mysql, "db", K::Procedure, "p"),
Some(("SHOW CREATE PROCEDURE `db`.`p`".into(), 2))
);
assert!(object_ddl(DbDriver::Sqlite, "main", K::Function, "f").is_none());
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
}
#[test]
fn postgres_ddl() {
let cols = [
-474
Переглянути файл
@@ -1,474 +0,0 @@
//! SSH tunnels for data sources: a listener on 127.0.0.1 whose connections are
//! forwarded to the database through `direct-tcpip` channels of an SSH session
//! (russh), optionally reached through `ProxyJump` hosts.
//!
//! Host keys are checked against `known_hosts`; an unknown key fails the
//! connection with [`OpenError::UnknownHostKey`] so the UI can ask the user,
//! and a changed key is always refused.
use crate::source::{DbSsh, DbSshAuth};
use ide_core::{IdeError, IdeResult, error::ErrorKind};
use parking_lot::Mutex;
use russh::client::{self, Handle};
use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(45);
const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
/// A host key the user has not trusted yet.
#[derive(Debug, Clone)]
pub struct UnknownHostKey {
pub host: String,
pub port: u16,
pub key: PublicKey,
}
impl UnknownHostKey {
/// `ED25519 SHA256:…`, as OpenSSH prints it.
pub fn fingerprint(&self) -> String {
format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256))
}
/// Records the key in `known_hosts` (blocking).
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts)
.map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display())))
}
}
#[derive(Debug)]
pub enum OpenError {
UnknownHostKey(Box<UnknownHostKey>),
Other(IdeError),
}
impl From<IdeError> for OpenError {
fn from(e: IdeError) -> Self {
OpenError::Other(e)
}
}
/// Where and how to connect.
pub struct TunnelSpec<'a> {
pub ssh: &'a DbSsh,
/// SSH password or key passphrase.
pub secret: Option<&'a str>,
/// The database, as seen from the SSH host.
pub target_host: &'a str,
pub target_port: u16,
pub known_hosts: &'a Path,
/// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]).
pub ssh_config: Option<&'a Path>,
}
/// An open tunnel; dropping it stops forwarding and closes the SSH sessions.
pub struct Tunnel {
port: u16,
session: Arc<Handle<Client>>,
/// Sessions of the `ProxyJump` hosts, kept alive under `session`.
_jumps: Vec<Handle<Client>>,
accept: JoinHandle<()>,
}
impl Tunnel {
/// Connects and starts listening on a free local port.
pub async fn open(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22));
match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await {
Ok(r) => r,
Err(_) => Err(IdeError::new(
ErrorKind::Io,
format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()),
)
.into()),
}
}
async fn open_inner(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
let hops = resolve_hops(spec.ssh, spec.ssh_config)?;
let mut sessions: Vec<Handle<Client>> = Vec::new();
for (i, hop) in hops.iter().enumerate() {
let target = i == hops.len() - 1;
let mut handle = match sessions.last() {
None => connect::<tokio::net::TcpStream>(hop, spec.known_hosts, None).await?,
Some(prev) => {
let channel = prev
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
.await
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
connect(hop, spec.known_hosts, Some(channel.into_stream())).await?
}
};
// Jump hosts always use the agent / identity files; the secret belongs to the target.
let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh };
authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?;
sessions.push(handle);
}
let session = Arc::new(sessions.pop().expect("at least one hop"));
// Fail fast with the real reason when the SSH host cannot reach the database
// (a refused forward otherwise surfaces as the driver's connect timeout).
let via = hops.last().expect("at least one hop").label();
session
.channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0)
.await
.map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?;
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?;
let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port();
let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port));
Ok(Tunnel { port, session, _jumps: sessions, accept })
}
/// The local port to connect to instead of the database.
pub fn port(&self) -> u16 {
self.port
}
/// The SSH session ended (network loss, server restart).
pub fn is_closed(&self) -> bool {
self.session.is_closed()
}
}
impl Drop for Tunnel {
fn drop(&mut self) {
self.accept.abort();
}
}
async fn forward(listener: TcpListener, session: Arc<Handle<Client>>, host: String, port: u16) {
loop {
let Ok((mut socket, peer)) = listener.accept().await else { return };
let session = session.clone();
let host = host.clone();
tokio::spawn(async move {
match session
.channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port()))
.await
{
Ok(channel) => {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await;
}
Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"),
}
});
}
}
fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError {
let why = match e {
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => {
"connection refused or host unreachable"
}
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => {
"port forwarding is not allowed by the SSH server (AllowTcpForwarding)"
}
_ => "",
};
let why = if why.is_empty() { e.to_string() } else { why.to_owned() };
io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}"))
}
fn io(message: impl Into<String>) -> IdeError {
IdeError::new(ErrorKind::Io, message)
}
// --- hosts ---------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hop {
host: String,
port: u16,
user: String,
identity_files: Vec<PathBuf>,
}
impl Hop {
fn label(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
/// `~/x` → `$HOME/x`.
fn expand(path: &str) -> PathBuf {
match (path.strip_prefix("~/"), home()) {
(Some(rest), Some(h)) => h.join(rest),
_ => PathBuf::from(path),
}
}
fn local_user() -> String {
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
}
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
let c = config
.and_then(|p| russh_config::parse_path(p, alias).ok())
.unwrap_or_else(|| russh_config::Config::default(alias));
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
(hop, c.host_config.proxy_jump.clone())
}
/// `[user@]host[:port]` of a `ProxyJump` entry.
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
let (user, rest) = match spec.rsplit_once('@') {
Some((u, r)) => (Some(u), r),
None => (None, spec),
};
let (host, port) = match rest.rsplit_once(':') {
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
_ => (rest, None),
};
let (mut hop, _) = config_hop(config, host);
if let Some(u) = user {
hop.user = u.to_owned();
}
if let Some(p) = port {
hop.port = p;
}
hop
}
/// The SSH hosts to go through, the tunnel's host last.
fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
let host = ssh.host.trim();
if host.is_empty() {
return Err(IdeError::invalid("SSH host is empty"));
}
let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh {
config_hop(config, host)
} else {
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
};
if let Some(p) = ssh.port {
hop.port = p;
}
if !ssh.user.trim().is_empty() {
hop.user = ssh.user.trim().to_owned();
}
let mut hops: Vec<Hop> = proxy_jump
.filter(|j| !j.eq_ignore_ascii_case("none"))
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
.unwrap_or_default();
hops.push(hop);
Ok(hops)
}
// --- connection ----------------------------------------------------------------
#[derive(Debug)]
enum HostKeyIssue {
Unknown(PublicKey),
Changed(usize),
}
pub struct Client {
host: String,
port: u16,
known_hosts: PathBuf,
issue: Arc<Mutex<Option<HostKeyIssue>>>,
}
impl client::Handler for Client {
type Error = russh::Error;
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
let key = match key {
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
};
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
Ok(true) => return Ok(true),
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
};
*self.issue.lock() = Some(issue);
Ok(false)
}
}
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, OpenError>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let config = Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: Some(Duration::from_secs(30)),
keepalive_max: 3,
nodelay: true,
..Default::default()
});
let issue = Arc::new(Mutex::new(None));
let handler =
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
let result = match stream {
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
Some(s) => client::connect_stream(config, s, handler).await,
};
result.map_err(|e| match issue.lock().take() {
Some(HostKeyIssue::Unknown(key)) => {
OpenError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key }))
}
Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new(
ErrorKind::PermissionDenied,
format!(
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
hop.host, hop.port
),
)),
None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
})
}
// --- authentication ------------------------------------------------------------
fn denied(message: String) -> IdeError {
IdeError::new(ErrorKind::PermissionDenied, message)
}
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
}
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
Ok(r.success())
}
/// Every identity of the running SSH agent, until one is accepted.
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
#[cfg(unix)]
let agent = keys::agent::client::AgentClient::connect_env().await;
#[cfg(windows)]
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
let Ok(mut agent) = agent else { return false };
let Ok(identities) = agent.request_identities().await else { return false };
let hash = rsa_hash(handle).await;
for id in identities {
let ok = match id {
keys::agent::AgentIdentity::PublicKey { key, .. } => {
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
}
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
}
};
if ok.unwrap_or(false) {
return true;
}
}
false
}
async fn authenticate(
handle: &mut Handle<Client>,
hop: &Hop,
mode: DbSshAuth,
key_file: &str,
secret: Option<&str>,
) -> IdeResult<()> {
let user = hop.user.as_str();
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
match mode {
DbSshAuth::Password => {
let Some(password) = secret else {
return Err(denied(format!("SSH password required for {}", hop.label())));
};
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
if r.success() { Ok(()) } else { Err(failed()) }
}
DbSshAuth::KeyPair => {
if key_file.trim().is_empty() {
return Err(IdeError::invalid("SSH private key file is not set"));
}
let path = expand(key_file.trim());
let key = load_key(&path, secret)?;
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
}
DbSshAuth::OpenSsh => {
if with_agent(handle, user).await {
return Ok(());
}
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
} else {
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
};
let mut locked: Option<PathBuf> = None;
for path in files.iter().filter(|p| p.is_file()) {
match load_key(path, secret) {
Ok(key) => {
if with_key(handle, user, key).await? {
return Ok(());
}
}
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
}
}
match locked {
// An encrypted key could not be tried: ask for its passphrase.
Some(path) if secret.is_none() => {
Err(denied(format!("SSH key passphrase required for {}", path.display())))
}
_ => Err(failed()),
}
}
}
}
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
match keys::load_secret_key(path, passphrase) {
Ok(k) => Ok(k),
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
Err(e) if passphrase.is_some() => {
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
}
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("config");
std::fs::write(
&config,
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
)
.unwrap();
let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() };
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
// Explicit fields win over the config.
let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh };
assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "[email protected]:22");
// Without OpenSSH mode the host is used as is.
let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh };
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
assert!(resolve_hops(&DbSsh::default(), None).is_err());
}
}
+74 -2
Переглянути файл
@@ -1,13 +1,14 @@
//! Introspection, queries and table edits against a real SQLite file.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbTableKind, SortKey};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
std::fs::File::create(root.join("app.db")).unwrap();
let db = DbManager::new(root.clone(), root.join("passwords.json"));
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "s1".into(),
name: "app".into(),
@@ -246,3 +247,74 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
assert!(db.ddl("s1", "main", "nope").await.is_err());
Ok(())
}
#[tokio::test]
async fn explains_queries_without_running_them() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
assert_eq!(ops, vec!["Index Scan", "Order By"]);
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
db.explain("s1", "DELETE FROM users", &c).await?;
assert_eq!(count(&db).await, "3");
Ok(())
}
#[tokio::test]
async fn records_executed_statements_in_the_query_history() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
db.execute("s1", "SELECT 1", 1, &c).await?;
assert!(db.execute("s1", "SELECT nope", 1, &c).await.is_err());
// Recorded off the async threads.
let mut texts = Vec::new();
for _ in 0..50 {
texts = db.history("s1").await?.into_iter().map(|e| e.text).collect::<Vec<_>>();
if texts.first().map(String::as_str) == Some("SELECT nope") {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert_eq!(texts[..2], ["SELECT nope", "SELECT 1"]);
db.remove_history("s1", Some(vec!["SELECT 1".into()])).await?;
assert!(!db.history("s1").await?.iter().any(|e| e.text == "SELECT 1"));
db.remove_history("s1", None).await?;
assert!(db.history("s1").await?.is_empty());
Ok(())
}
#[tokio::test]
async fn lists_triggers_and_their_ddl() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
db.execute("s1", "CREATE TRIGGER users_touch AFTER UPDATE ON users BEGIN SELECT 1; END", 1, &c).await?;
let d = db.table_details("s1", "main", "users").await?;
let triggers: Vec<_> = d.triggers.iter().map(|t| (t.name.as_str(), t.timing.as_str(), t.events.as_str())).collect();
assert_eq!(triggers, vec![("users_touch", "after", "update")]);
let ddl = db.object_ddl("s1", "main", ide_db::DbObjectKind::Trigger, "users_touch").await?;
assert!(ddl.starts_with("CREATE TRIGGER users_touch"), "{ddl}");
// SQLite has neither routines nor sequences.
assert_eq!(db.schema_objects("s1", "main").await?, ide_db::SchemaObjects::default());
Ok(())
}
#[tokio::test]
async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeResult<()> {
let (tmp, db) = setup().await;
let mut s = db.sources()?.remove(0);
s.properties = vec![DbProperty { name: "cache_size".into(), value: "-1234".into() }];
s.options = DbOptions {
startup_script: "CREATE TEMP TABLE boot (x); INSERT INTO boot VALUES (7);".into(),
..Default::default()
};
db.save_sources(vec![s.clone()]).await?;
let c = CancellationToken::new();
let r = db.execute("s1", "SELECT (SELECT x FROM boot), (SELECT cache_size FROM pragma_cache_size)", 10, &c).await?;
assert_eq!(r.rows, vec![vec![Some("7".into()), Some("-1234".into())]]);
// URL only: the file comes from the URL.
let url = format!("jdbc:sqlite:{}", tmp.path().join("app.db").display());
db.save_sources(vec![DataSource { url_only: true, url, file: None, ..s }]).await?;
assert_eq!(db.schemas("s1").await?, vec!["main"]);
Ok(())
}
-226
Переглянути файл
@@ -1,226 +0,0 @@
//! SSH tunnels against an in-process russh server that forwards `direct-tcpip`
//! channels to a local echo server.
use ide_core::error::ErrorKind;
use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec};
use ide_db::{DbSsh, DbSshAuth};
use russh::Channel;
use russh::keys::{PrivateKey, PublicKey, decode_secret_key};
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name)
}
fn key(name: &str, passphrase: Option<&str>) -> PrivateKey {
decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap()
}
#[derive(Clone)]
struct Sshd {
client_key: PublicKey,
}
impl server::Server for Sshd {
type Handler = Sshd;
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Sshd {
self.clone()
}
}
impl server::Handler for Sshd {
type Error = russh::Error;
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
}
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host: &str,
port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
});
Ok(())
}
}
/// Echo server standing in for the database.
async fn echo() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut s, _)) = listener.accept().await {
tokio::spawn(async move {
let (mut r, mut w) = s.split();
let _ = tokio::io::copy(&mut r, &mut w).await;
});
}
});
port
}
async fn sshd() -> u16 {
let config = Arc::new(server::Config {
keys: vec![key("host", None)],
auth_rejection_time: std::time::Duration::from_millis(10),
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
..Default::default()
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
tokio::spawn(async move {
let _ = server.run_on_socket(config, &listener).await;
});
port
}
struct Env {
_tmp: tempfile::TempDir,
known_hosts: PathBuf,
ssh_port: u16,
db_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let known_hosts = tmp.path().join("known_hosts");
Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await }
}
impl Env {
fn ssh(&self, auth: DbSshAuth) -> DbSsh {
DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth,
..Default::default()
}
}
async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
Tunnel::open(TunnelSpec {
ssh,
secret,
target_host: "127.0.0.1",
target_port: self.db_port,
known_hosts: &self.known_hosts,
ssh_config: None,
})
.await
}
/// Trusts the server's key on the first connection, as the UI does.
async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
match self.open(ssh, secret).await {
Err(OpenError::UnknownHostKey(k)) => {
k.trust(&self.known_hosts).unwrap();
self.open(ssh, secret).await
}
other => other,
}
}
}
async fn round_trip(t: &Tunnel) {
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
s.write_all(b"SELECT 1").await.unwrap();
let mut buf = [0u8; 8];
s.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"SELECT 1");
}
fn denied(r: Result<Tunnel, OpenError>) -> String {
match r {
Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"),
Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
Ok(_) => panic!("expected permission denied, connected"),
}
}
#[tokio::test]
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
let env = env().await;
let ssh = env.ssh(DbSshAuth::Password);
let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else {
panic!("expected an unknown host key");
};
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
k.trust(&env.known_hosts).unwrap();
round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await;
// Another key recorded for the same host: refused, never offered for trust.
let other = key("client", None).public_key().to_openssh().unwrap();
std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
let msg = denied(env.open(&ssh, Some("secret")).await);
assert!(msg.contains("has changed"), "{msg}");
}
#[tokio::test]
async fn password_authentication() {
let env = env().await;
let ssh = env.ssh(DbSshAuth::Password);
let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap();
round_trip(&tunnel).await;
round_trip(&tunnel).await;
assert!(!tunnel.is_closed());
assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required"));
assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed"));
}
#[tokio::test]
async fn key_pair_authentication_with_and_without_passphrase() {
let env = env().await;
let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) };
round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await;
// An encrypted key the server does not accept: passphrase first, then the key is refused.
let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() };
assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required"));
assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase"));
assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
}
#[tokio::test]
async fn unreachable_database_fails_fast_with_the_reason() {
let env = env().await;
let ssh = env.ssh(DbSshAuth::Password);
drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap());
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
let r = Tunnel::open(TunnelSpec {
ssh: &ssh,
secret: Some("secret"),
target_host: "127.0.0.1",
target_port: closed,
known_hosts: &env.known_hosts,
ssh_config: None,
})
.await;
let Err(OpenError::Other(e)) = r else { panic!("expected an error") };
assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message);
}
+3
Переглянути файл
@@ -15,3 +15,6 @@ which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+402
Переглянути файл
@@ -0,0 +1,402 @@
//! Docker connections (which daemon the CLI talks to) and registries, as the
//! Services tool window's "Add Service" menu creates them. Both are app-wide
//! like in IDEA: `docker.json` in the app data directory; registry passwords
//! live beside it in an owner-only `docker-passwords.json`.
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Which daemon a docker command talks to.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum DockerEndpoint {
/// Whatever the CLI is configured for (`DOCKER_HOST`, the current context).
#[default]
Default,
/// A local daemon socket, e.g. `/var/run/docker.sock` (or a `npipe://` URL on Windows).
Socket { path: String },
/// A remote daemon, e.g. `tcp://build:2376` or `ssh://me@build`; with TLS when
/// `tls_cert_dir` holds `ca.pem`, `cert.pem` and `key.pem`.
Tcp { host: String, tls_cert_dir: Option<String> },
/// A named `docker context`.
Context { name: String },
}
impl DockerEndpoint {
/// Global CLI options selecting this endpoint (before the subcommand).
pub fn global_args(&self) -> Vec<String> {
match self {
Self::Default => Vec::new(),
Self::Socket { path } => {
let path = path.trim();
let url = if path.contains("://") { path.to_owned() } else { format!("unix://{path}") };
vec!["--host".into(), url]
}
Self::Tcp { host, tls_cert_dir } => {
let host = host.trim();
let url = if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") };
let mut args = vec!["--host".into(), url];
if let Some(dir) = tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
let file = |name: &str| Path::new(dir).join(name).to_string_lossy().into_owned();
args.extend([
"--tlsverify".into(),
"--tlscacert".into(),
file("ca.pem"),
"--tlscert".into(),
file("cert.pem"),
"--tlskey".into(),
file("key.pem"),
]);
}
args
}
Self::Context { name } => vec!["--context".into(), name.trim().to_owned()],
}
}
/// Adds the global options to `cmd`; an explicit endpoint also drops the
/// inherited `DOCKER_HOST` / `DOCKER_CONTEXT`, which would conflict.
pub(crate) fn apply(&self, cmd: &mut Command) {
if *self != Self::Default {
cmd.env_remove("DOCKER_HOST").env_remove("DOCKER_CONTEXT").env_remove("DOCKER_TLS_VERIFY");
}
cmd.args(self.global_args());
}
/// The same selection as environment variables, for docker command lines run by
/// others (run configurations). An empty value clears an inherited variable: the
/// CLI treats `DOCKER_HOST=""` as unset.
pub fn env_vars(&self) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
let mut set = |k: &str, v: String| {
env.insert(k.to_owned(), v);
};
match self {
Self::Default => {}
Self::Socket { path } => {
let path = path.trim();
set("DOCKER_HOST", if path.contains("://") { path.to_owned() } else { format!("unix://{path}") });
set("DOCKER_CONTEXT", String::new());
set("DOCKER_TLS_VERIFY", String::new());
}
Self::Tcp { host, tls_cert_dir } => {
let host = host.trim();
set("DOCKER_HOST", if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") });
set("DOCKER_CONTEXT", String::new());
match tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
// `DOCKER_CERT_PATH` holds `ca.pem`, `cert.pem` and `key.pem`, as `global_args` expects.
Some(dir) => {
set("DOCKER_TLS_VERIFY", "1".into());
set("DOCKER_CERT_PATH", dir.to_owned());
}
None => set("DOCKER_TLS_VERIFY", String::new()),
}
}
Self::Context { name } => {
set("DOCKER_CONTEXT", name.trim().to_owned());
set("DOCKER_HOST", String::new());
set("DOCKER_TLS_VERIFY", String::new());
}
}
env
}
pub fn validate(&self) -> IdeResult<()> {
let empty = match self {
Self::Default => false,
Self::Socket { path } => path.trim().is_empty(),
Self::Tcp { host, .. } => host.trim().is_empty(),
Self::Context { name } => name.trim().is_empty() || name.trim().starts_with('-'),
};
if empty { Err(IdeError::invalid("the Docker connection needs a socket, host or context")) } else { Ok(()) }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerConnection {
pub id: String,
pub name: String,
pub endpoint: DockerEndpoint,
}
/// A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerRegistry {
pub id: String,
pub name: String,
/// `docker.io`, `ghcr.io`, `registry.example.com:5000`.
pub address: String,
#[serde(default)]
pub username: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerSettings {
#[serde(default = "default_connections")]
pub connections: Vec<DockerConnection>,
#[serde(default)]
pub registries: Vec<DockerRegistry>,
}
impl Default for DockerSettings {
fn default() -> Self {
Self { connections: default_connections(), registries: Vec::new() }
}
}
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
fn default_connections() -> Vec<DockerConnection> {
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
}
const SETTINGS_FILE: &str = "docker.json";
const PASSWORDS_FILE: &str = "docker-passwords.json";
pub fn load_settings(data_dir: &Path) -> DockerSettings {
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("{SETTINGS_FILE}: {e}");
DockerSettings::default()
}),
Err(_) => DockerSettings::default(),
}
}
pub fn save_settings(data_dir: &Path, settings: &DockerSettings) -> IdeResult<()> {
for c in &settings.connections {
if c.id.is_empty() || c.name.trim().is_empty() {
return Err(IdeError::invalid("every Docker connection needs a name"));
}
c.endpoint.validate()?;
}
for r in &settings.registries {
if r.id.is_empty() || r.address.trim().is_empty() {
return Err(IdeError::invalid("every Docker registry needs an address"));
}
}
let bytes = serde_json::to_vec_pretty(settings).map_err(|e| IdeError::internal(e.to_string()))?;
write_file(&data_dir.join(SETTINGS_FILE), &bytes, false)?;
// Forget passwords of registries that are gone.
let mut passwords = read_passwords(data_dir);
let before = passwords.len();
passwords.retain(|id, _| settings.registries.iter().any(|r| &r.id == id));
if passwords.len() != before { write_passwords(data_dir, &passwords) } else { Ok(()) }
}
fn read_passwords(data_dir: &Path) -> BTreeMap<String, String> {
std::fs::read(data_dir.join(PASSWORDS_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn write_passwords(data_dir: &Path, all: &BTreeMap<String, String>) -> IdeResult<()> {
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
write_file(&data_dir.join(PASSWORDS_FILE), &bytes, true)
}
pub fn registry_password(data_dir: &Path, id: &str) -> Option<String> {
read_passwords(data_dir).remove(id)
}
/// Saves (or with `None` forgets) the password of registry `id`.
pub fn set_registry_password(data_dir: &Path, id: &str, password: Option<&str>) -> IdeResult<()> {
let mut all = read_passwords(data_dir);
match password {
Some(p) => all.insert(id.to_owned(), p.to_owned()),
None => all.remove(id),
};
write_passwords(data_dir, &all)
}
fn write_file(path: &Path, bytes: &[u8], private: bool) -> IdeResult<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
if private {
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
}
#[cfg(not(unix))]
let _ = private;
std::io::Write::write_all(&mut options.open(&tmp)?, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
/// Canonical registry host: no scheme or path; Docker Hub's aliases become `docker.io`.
pub fn registry_host(address: &str) -> String {
let a = address.trim();
let a = a.split_once("://").map_or(a, |(_, rest)| rest);
let host = a.split('/').next().unwrap_or_default().to_ascii_lowercase();
match host.as_str() {
"index.docker.io" | "registry-1.docker.io" | "registry.hub.docker.com" | "hub.docker.com" => "docker.io".into(),
_ => host,
}
}
/// Registry host an image reference pulls from (`postgres:16` → `docker.io`).
pub fn reference_registry(reference: &str) -> String {
match reference.split_once('/') {
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => registry_host(first),
_ => "docker.io".into(),
}
}
/// Credentials for a `docker login`.
#[derive(Clone, Debug)]
pub struct RegistryAuth {
pub address: String,
pub username: String,
pub password: String,
}
/// The saved registry (with a password) matching `reference`, if any.
pub fn auth_for(data_dir: &Path, reference: &str) -> Option<RegistryAuth> {
let host = reference_registry(reference);
let settings = load_settings(data_dir);
let registry =
settings.registries.into_iter().find(|r| registry_host(&r.address) == host && !r.username.is_empty())?;
let password = registry_password(data_dir, &registry.id)?;
Some(RegistryAuth { address: registry.address, username: registry.username, password })
}
/// A throwaway docker config directory: logins happen there, never in the user's
/// `~/.docker/config.json`. Contexts are copied so a context endpoint still resolves.
pub(crate) struct TempConfig {
pub dir: PathBuf,
}
impl TempConfig {
pub fn new() -> IdeResult<Self> {
let nanos =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let dir = std::env::temp_dir().join(format!("openidea-docker-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
if let Some(contexts) = user_config_dir().map(|d| d.join("contexts")).filter(|d| d.is_dir()) {
let _ = copy_dir(&contexts, &dir.join("contexts"));
}
Ok(Self { dir })
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn user_config_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
return Some(PathBuf::from(dir));
}
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })?;
Some(PathBuf::from(home).join(".docker"))
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)?.flatten() {
let target = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &target)?;
} else {
std::fs::copy(e.path(), target)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_args() {
assert!(DockerEndpoint::Default.global_args().is_empty());
assert_eq!(
DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.global_args(),
["--host", "unix:///var/run/docker.sock"]
);
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.global_args();
assert_eq!(tls[..3], ["--host", "tcp://build:2376", "--tlsverify"]);
assert!(tls.contains(&Path::new("/certs").join("key.pem").to_string_lossy().into_owned()));
assert_eq!(
DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: Some(" ".into()) }.global_args(),
["--host", "ssh://me@box"]
);
assert_eq!(DockerEndpoint::Context { name: "orbstack".into() }.global_args(), ["--context", "orbstack"]);
assert!(DockerEndpoint::Context { name: "--x".into() }.validate().is_err());
}
#[test]
fn endpoint_env() {
assert!(DockerEndpoint::Default.env_vars().is_empty());
let socket = DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.env_vars();
assert_eq!(socket["DOCKER_HOST"], "unix:///var/run/docker.sock");
assert_eq!(socket["DOCKER_CONTEXT"], "");
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.env_vars();
assert_eq!(
(tls["DOCKER_HOST"].as_str(), tls["DOCKER_TLS_VERIFY"].as_str(), tls["DOCKER_CERT_PATH"].as_str()),
("tcp://build:2376", "1", "/certs")
);
let ssh = DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: None }.env_vars();
assert_eq!((ssh["DOCKER_HOST"].as_str(), ssh["DOCKER_TLS_VERIFY"].as_str()), ("ssh://me@box", ""));
assert!(!ssh.contains_key("DOCKER_CERT_PATH"));
let ctx = DockerEndpoint::Context { name: " orbstack ".into() }.env_vars();
assert_eq!((ctx["DOCKER_CONTEXT"].as_str(), ctx["DOCKER_HOST"].as_str()), ("orbstack", ""));
}
#[test]
fn registries_match_references() {
assert_eq!(reference_registry("postgres:16"), "docker.io");
assert_eq!(reference_registry("library/postgres"), "docker.io");
assert_eq!(reference_registry("ghcr.io/org/app:1"), "ghcr.io");
assert_eq!(reference_registry("localhost:5000/app"), "localhost:5000");
assert_eq!(registry_host("https://index.docker.io/v1/"), "docker.io");
assert_eq!(registry_host("GHCR.io"), "ghcr.io");
}
#[test]
fn settings_round_trip_and_passwords() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
let settings = DockerSettings {
connections: vec![DockerConnection {
id: "c1".into(),
name: "Build box".into(),
endpoint: DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None },
}],
registries: vec![DockerRegistry {
id: "r1".into(),
name: "GHCR".into(),
address: "ghcr.io".into(),
username: "me".into(),
}],
};
save_settings(dir, &settings).unwrap();
assert_eq!(load_settings(dir), settings);
set_registry_password(dir, "r1", Some("s3cret")).unwrap();
let auth = auth_for(dir, "ghcr.io/org/app").unwrap();
assert_eq!((auth.username.as_str(), auth.password.as_str()), ("me", "s3cret"));
assert!(auth_for(dir, "postgres").is_none());
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
assert_eq!(registry_password(dir, "r1"), None);
// An empty list stays empty (the default connection is only for a missing file).
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
assert!(load_settings(dir).connections.is_empty());
}
}
+109 -28
Переглянути файл
@@ -4,9 +4,11 @@
//!
//! Every function blocks; callers run them off the UI / command threads.
mod connection;
mod model;
mod process;
pub use connection::*;
pub use model::*;
use ide_core::{CancellationToken, IdeError, IdeResult};
@@ -61,21 +63,38 @@ pub fn docker_executable() -> Option<PathBuf> {
.clone()
}
/// Environment for docker command lines a run configuration launches: the endpoint's
/// variables plus a `PATH` that starts with docker's directory (GUI apps on macOS
/// lack the usual install locations), so `docker` / `docker compose` resolve.
pub fn run_env(ep: &DockerEndpoint) -> std::collections::BTreeMap<String, String> {
let mut env = ep.env_vars();
if let Some(dir) = docker_executable().as_deref().and_then(Path::parent) {
let current = ide_core::process::host_var_os("PATH").unwrap_or_default();
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
dirs.extend(std::env::split_paths(&current).filter(|d| d != dir));
if let Ok(path) = std::env::join_paths(dirs) {
env.insert("PATH".into(), path.to_string_lossy().into_owned());
}
}
env
}
fn binary() -> IdeResult<PathBuf> {
docker_executable()
.ok_or_else(|| IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH"))
}
fn command(args: &[&str]) -> IdeResult<Command> {
fn command(ep: &DockerEndpoint, args: &[&str]) -> IdeResult<Command> {
let mut cmd = Command::new(binary()?);
ep.apply(&mut cmd);
cmd.args(args).env("DOCKER_CLI_HINTS", "false").env("NO_COLOR", "1");
tracing::debug!(?args, "docker");
Ok(cmd)
}
/// Runs a docker command, mapping a non-zero exit to an error with docker's message.
fn run(args: &[&str], timeout: Duration) -> IdeResult<String> {
let out = process::run(command(args)?, timeout, None)?;
fn run(ep: &DockerEndpoint, args: &[&str], timeout: Duration) -> IdeResult<String> {
let out = process::run(command(ep, args)?, timeout, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(args, &out.stderr, &out.stdout)) }
}
@@ -94,8 +113,8 @@ fn failure(args: &[&str], stderr: &str, stdout: &str) -> IdeError {
}
/// Whether docker can be used right now.
pub fn availability() -> DockerAvailability {
let Ok(cmd) = command(&["version", "--format", "{{json .}}"]) else {
pub fn availability(ep: &DockerEndpoint) -> DockerAvailability {
let Ok(cmd) = command(ep, &["version", "--format", "{{json .}}"]) else {
return DockerAvailability::NotInstalled;
};
match process::run(cmd, Duration::from_secs(15), None) {
@@ -105,8 +124,8 @@ pub fn availability() -> DockerAvailability {
}
/// Availability plus containers, images, volumes and networks (listed in parallel).
pub fn snapshot() -> DockerSnapshot {
let availability = availability();
pub fn snapshot(ep: &DockerEndpoint) -> DockerSnapshot {
let availability = availability(ep);
let executable = docker_executable().map(|p| ide_core::ipc_path::to_ipc(&p));
let mut snap = DockerSnapshot {
availability,
@@ -116,16 +135,17 @@ pub fn snapshot() -> DockerSnapshot {
volumes: Vec::new(),
networks: Vec::new(),
errors: Vec::new(),
global_args: ep.global_args(),
};
if !matches!(snap.availability, DockerAvailability::Ready { .. }) {
return snap;
}
const FMT: &str = "{{json .}}";
let (containers, images, volumes, networks) = std::thread::scope(|s| {
let c = s.spawn(|| run(&["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
let i = s.spawn(|| run(&["image", "ls", "--no-trunc", "--format", FMT], QUICK));
let v = s.spawn(|| run(&["volume", "ls", "--format", FMT], QUICK));
let n = s.spawn(|| run(&["network", "ls", "--no-trunc", "--format", FMT], QUICK));
let c = s.spawn(|| run(ep, &["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
let i = s.spawn(|| run(ep, &["image", "ls", "--no-trunc", "--format", FMT], QUICK));
let v = s.spawn(|| run(ep, &["volume", "ls", "--format", FMT], QUICK));
let n = s.spawn(|| run(ep, &["network", "ls", "--no-trunc", "--format", FMT], QUICK));
let join = |h: std::thread::ScopedJoinHandle<'_, IdeResult<String>>| {
h.join().unwrap_or_else(|_| Err(IdeError::internal("docker listing panicked")))
};
@@ -178,13 +198,13 @@ impl ContainerOp {
}
/// Starts / stops / … the containers `ids`.
pub fn container_op(ids: &[String], op: ContainerOp) -> IdeResult<()> {
pub fn container_op(ep: &DockerEndpoint, ids: &[String], op: ContainerOp) -> IdeResult<()> {
if ids.is_empty() {
return Ok(());
}
let mut args = vec!["container", op.verb()];
args.extend(ids.iter().map(String::as_str));
run(&args, LIFECYCLE).map(drop)
run(ep, &args, LIFECYCLE).map(drop)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -208,7 +228,7 @@ impl DockerObject {
}
/// `docker <object> rm` (`force`: stop running containers / untag used images first).
pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
pub fn remove(ep: &DockerEndpoint, object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
if ids.is_empty() {
return Ok(());
}
@@ -217,12 +237,12 @@ pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()
args.push("--force");
}
args.extend(ids.iter().map(String::as_str));
run(&args, LIFECYCLE).map(drop)
run(ep, &args, LIFECYCLE).map(drop)
}
/// `docker inspect` of one object, pretty-printed.
pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
let out = run(&["inspect", "--type", object.noun(), id], QUICK)?;
pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult<String> {
let out = run(ep, &["inspect", "--type", object.noun(), id], QUICK)?;
match serde_json::from_str::<serde_json::Value>(&out) {
// A one-element array for a single id: show the object itself.
Ok(serde_json::Value::Array(mut items)) if items.len() == 1 => {
@@ -236,6 +256,7 @@ pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
ep: &DockerEndpoint,
id: &str,
tail: u32,
follow: bool,
@@ -252,16 +273,16 @@ pub fn logs(
args.push("--timestamps");
}
args.push(id);
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, text| {
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, text| {
on_line(DockerLogLine { stderr: stream == Stream::Stderr, text })
})?;
if ok { Ok(()) } else { Err(failure(&args, &stderr.join("\n"), "")) }
}
/// Follows `docker events` until it ends (daemon gone) or `cancel` fires.
pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
pub fn watch_events(ep: &DockerEndpoint, cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
let args = ["events", "--format", "{{json .}}"];
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, line| {
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, line| {
if stream == Stream::Stdout
&& let Some(e) = parse_event(&line)
{
@@ -272,9 +293,19 @@ pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent))
}
/// Runs a long docker command (pull, prune, compose…), streaming its output lines.
fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
fn run_streaming(
ep: &DockerEndpoint,
config: Option<&Path>,
args: &[&str],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
on_output(format!("docker {}", args.join(" ")));
let (ok, stderr) = process::stream(command(args)?, cancel, &mut |_, line| {
let mut cmd = command(ep, args)?;
if let Some(dir) = config {
cmd.env("DOCKER_CONFIG", dir);
}
let (ok, stderr) = process::stream(cmd, cancel, &mut |_, line| {
if !line.trim().is_empty() {
on_output(line);
}
@@ -282,13 +313,57 @@ fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(S
if ok { Ok(()) } else { Err(failure(args, &stderr.join("\n"), "")) }
}
/// `docker pull <reference>`.
pub fn pull(reference: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
/// `docker pull <reference>`; with `auth` it logs in to the registry first, in a
/// throwaway config directory (the user's docker config stays untouched).
pub fn pull(
ep: &DockerEndpoint,
reference: &str,
auth: Option<&RegistryAuth>,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let reference = reference.trim();
if reference.is_empty() || reference.starts_with('-') {
return Err(IdeError::invalid("enter an image name, e.g. postgres:16"));
}
run_streaming(&["pull", reference], cancel, on_output)
let Some(auth) = auth else { return run_streaming(ep, None, &["pull", reference], cancel, on_output) };
let config = TempConfig::new()?;
on_output(format!("Logging in to {} as {}", auth.address, auth.username));
login_in(ep, &config.dir, auth, Some(cancel))?;
run_streaming(ep, Some(&config.dir), &["pull", reference], cancel, on_output)
}
/// `docker login` with `auth`; returns docker's answer (`Login Succeeded`). Nothing is
/// stored in the user's docker config.
pub fn login(ep: &DockerEndpoint, auth: &RegistryAuth) -> IdeResult<String> {
let config = TempConfig::new()?;
login_in(ep, &config.dir, auth, None)
}
fn login_in(
ep: &DockerEndpoint,
config: &Path,
auth: &RegistryAuth,
cancel: Option<&CancellationToken>,
) -> IdeResult<String> {
let host = registry_host(&auth.address);
let user = auth.username.trim();
if host.is_empty() || host.starts_with('-') || user.is_empty() || user.starts_with('-') {
return Err(IdeError::invalid("the registry needs an address and a username"));
}
let mut args = vec!["login", "--username", user, "--password-stdin"];
// Docker Hub is the CLI's default server.
if host != "docker.io" {
args.push(&host);
}
let mut cmd = command(ep, &args)?;
cmd.env("DOCKER_CONFIG", config);
let out = process::run_with_input(cmd, QUICK, cancel, Some(&auth.password))?;
if out.success {
Ok(out.stdout.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("Login Succeeded").to_owned())
} else {
Err(failure(&args, &out.stderr, &out.stdout))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -306,7 +381,12 @@ pub enum PruneTarget {
}
/// `docker <object> prune --force`.
pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
pub fn prune(
ep: &DockerEndpoint,
target: PruneTarget,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
let args: &[&str] = match target {
PruneTarget::Containers => &["container", "prune", "--force"],
PruneTarget::Images => &["image", "prune", "--force"],
@@ -315,7 +395,7 @@ pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn
PruneTarget::Networks => &["network", "prune", "--force"],
PruneTarget::System => &["system", "prune", "--force"],
};
run_streaming(args, cancel, on_output)
run_streaming(ep, None, args, cancel, on_output)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -332,6 +412,7 @@ pub enum ComposeOp {
/// labels. `files` / `working_dir` (from the labels too) are passed when they still
/// exist, so commands that need the model keep working.
pub fn compose_op(
ep: &DockerEndpoint,
project: &str,
files: &[String],
working_dir: Option<&str>,
@@ -357,5 +438,5 @@ pub fn compose_op(
ComposeOp::Restart => "restart",
ComposeOp::Down => "down",
});
run_streaming(&args, cancel, on_output)
run_streaming(ep, None, &args, cancel, on_output)
}
+3
Переглянути файл
@@ -105,6 +105,9 @@ pub struct DockerSnapshot {
pub networks: Vec<DockerNetwork>,
/// Lists that failed although the daemon answered (shown, not fatal).
pub errors: Vec<String>,
/// Global CLI options selecting the connection's daemon (for `docker exec` in a terminal).
#[serde(default)]
pub global_args: Vec<String>,
}
/// One relevant `docker events` entry.
+19 -6
Переглянути файл
@@ -25,11 +25,7 @@ fn prepare(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
ide_core::process::prepare(cmd);
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
@@ -64,9 +60,26 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
run_with_input(cmd, timeout, cancel, None)
}
/// [`run`] with `input` written to the child's stdin (e.g. `--password-stdin`).
pub(crate) fn run_with_input(
mut cmd: Command,
timeout: Duration,
cancel: Option<&CancellationToken>,
input: Option<&str>,
) -> IdeResult<Output> {
prepare(&mut cmd);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = spawn(&mut cmd)?;
if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
// Dropping `stdin` afterwards closes it, so the CLI sees EOF.
let _ = std::io::Write::write_all(&mut stdin, text.as_bytes());
}
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
+1 -5
Переглянути файл
@@ -25,11 +25,7 @@ fn prepare(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
ide_core::process::prepare(cmd);
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
+50
Переглянути файл
@@ -0,0 +1,50 @@
[package]
name = "ide-host"
description = "The IDE backend without a UI toolkit: projects per window, terminals, processes, and the JSON-RPC server for remote development"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
ide-workspace.workspace = true
ide-language.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
ide-lsp.workspace = true
ide-vcs.workspace = true
ide-dap.workspace = true
ide-build.workspace = true
ide-analysis.workspace = true
ide-coverage.workspace = true
ide-spell.workspace = true
ide-terminal.workspace = true
ide-run.workspace = true
ide-sdk.workspace = true
ide-http.workspace = true
ide-db.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio = { workspace = true, features = ["io-std"] }
tracing.workspace = true
tracing-subscriber.workspace = true
parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
tempfile.workspace = true
+75
Переглянути файл
@@ -0,0 +1,75 @@
//! `openidea-server --stdio`: the IDE backend for remote development, serving
//! one desktop client over stdin / stdout (started by the client over SSH).
use ide_host::rpc::{self, Dispatcher};
use ide_host::{Host, HostConfig};
use ide_language::LanguageRegistry;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version") {
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
return;
}
if !args.iter().any(|a| a == "--stdio") {
eprintln!("usage: openidea-server --stdio | --version");
std::process::exit(2);
}
// stdout carries the protocol: logs go to stderr.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(tracing_subscriber::EnvFilter::try_from_env("OPENIDEA_LOG").unwrap_or_else(|_| "info".into()))
.init();
let home = dirs::home_dir().unwrap_or_default();
let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea"));
// An SSH exec channel gets a minimal PATH: take the login shell's, as the
// desktop app does, plus the IDE-managed tools. Before any other thread starts.
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server");
let data_dir = base.join("data");
ide_tools::extend_process_path(&data_dir);
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
if let Some(refresh) = refresh_shell_path {
refresh();
}
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
let code = runtime.block_on(run(base));
std::process::exit(code);
}
async fn run(base: PathBuf) -> i32 {
let (config_dir, data_dir) = (base.join("config"), base.join("data"));
for dir in [&config_dir, &data_dir] {
if let Err(e) = std::fs::create_dir_all(dir) {
eprintln!("cannot create {}: {e}", dir.display());
return 1;
}
}
let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) {
Ok(l) => Arc::new(l),
Err(e) => {
eprintln!("language definitions: {e}");
return 1;
}
};
let peer = Arc::new(OnceLock::new());
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
data_dir,
languages,
syntax: Arc::default(),
sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
}));
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
// `process::exit` skips destructors: nothing started for the client may outlive it.
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
tracing::warn!("language servers did not shut down in time");
}
0
}
+162
Переглянути файл
@@ -0,0 +1,162 @@
//! The IDE backend without a UI toolkit: open projects per window, terminals,
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod ops;
mod project;
pub mod rpc;
pub mod shell_env;
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId, SharedSink};
use ide_language::{LanguageRegistry, Syntax};
use ide_run::ProcessManager;
use ide_terminal::TerminalManager;
use ide_workspace::SettingsStore;
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
/// Event sink for one window (by label).
pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
pub sink_for: SinkFactory,
/// The window that owns what `window` starts (e.g. a settings window's parent).
pub owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
}
pub struct Host {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
pub debugger: Arc<ide_dap::DebugManager>,
/// SSH connections of Settings › SSH Configurations.
pub ssh: Arc<ide_ssh::SshManager>,
/// SFTP deployment, remote file editing and runs on run targets.
pub remote: Arc<ide_remote::RemoteService>,
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>>>,
requests: Mutex<HashMap<RequestId, CancellationToken>>,
}
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
Self {
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
global_settings,
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
processes: ProcessManager::new(),
debugger: ide_dap::DebugManager::new(languages.clone()),
languages,
syntax,
config_dir,
data_dir,
sink_for,
owner,
runtime,
projects: RwLock::default(),
requests: Mutex::default(),
}
}
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
pub fn syntax(&self) -> Arc<Syntax> {
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
}
/// The project of `window` (or of the window owning it).
pub fn project(&self, window: &str) -> IdeResult<Arc<Project>> {
self.projects
.read()
.get((self.owner)(window))
.cloned()
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
}
/// The window that owns what `window` starts.
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
(self.owner)(window)
}
/// Event sink for things `window` starts (debug sessions, runs, terminals): its
/// project's window-scoped sink, or a sink for that window when no project is open.
pub fn window_sink(&self, window: &str) -> SharedSink {
match self.project(window) {
Ok(p) => p.sink.clone(),
Err(_) => (self.sink_for)((self.owner)(window)),
}
}
pub fn close_project(&self, window: &str) {
if let Some(project) = self.projects.write().remove(window) {
project.agents.shutdown();
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
/// Stops what this host started: language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in projects {
project.agents.shutdown();
project.lsp.shutdown_all().await;
}
}
// ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ----
pub fn begin_request(&self, id: RequestId) -> CancellationToken {
let token = CancellationToken::new();
if let Some(old) = self.requests.lock().insert(id, token.clone()) {
old.cancel();
}
token
}
pub fn end_request(&self, id: RequestId) {
self.requests.lock().remove(&id);
}
pub fn cancel_request(&self, id: RequestId) {
if let Some(token) = self.requests.lock().remove(&id) {
token.cancel();
}
}
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
self.projects.write().insert(window.to_owned(), project);
}
}
+116
Переглянути файл
@@ -0,0 +1,116 @@
//! Agents tool window: coding agent tasks of the project (over ACP). In a remote
//! window they run on the host, next to the project they work on.
use super::Emit;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath};
/// Streams the project's agent events to this window; returns every task.
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
Ok(host.project(window)?.agents.subscribe(on_event))
}
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(host.project(window)?.agents.providers())
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
/// before a task exists (skills and custom commands on disk included).
pub async fn commands(
host: &Host,
window: &str,
provider: String,
task: Option<String>,
) -> IdeResult<Vec<AgentCommand>> {
host.project(window)?.agents.commands(&provider, task.as_deref()).await
}
/// Entries of `task`'s conversation, or of its subagent `subagent`'s.
pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<Vec<AgentEntry>> {
host.project(window)?.agents.entries(&task, subagent)
}
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
host.project(window)?.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
host.project(window)?.agents.prompt(&task, prompt, now).await
}
/// Drops a queued message and returns it (to edit it).
pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeResult<AgentQueued> {
host.project(window)?.agents.unqueue(&task, &queued)
}
/// Sends a queued message now, interrupting the current turn.
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
host.project(window)?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
host.project(window)?.agents.set_config(&task, &option, value).await
}
/// Stops the task's current turn, or with `subagent` only that subagent's work.
pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<()> {
host.project(window)?.agents.cancel(&task, subagent)
}
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.project(window)?.agents.respond(&task, permission, option)
}
pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.archive(&task)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.project(window)?.agents.delete(&task, remove_worktree).await
}
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
host.project(window)?.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_prompt", |c| async move {
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
});
d.add("agent_unqueue", |c| async move { unqueue(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) });
d.add(
"agent_send_queued_now",
|c| async move { send_queued_now(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) },
);
d.add("agent_set_config", |c| async move {
set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await
});
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
);
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
}
+280
Переглянути файл
@@ -0,0 +1,280 @@
//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics),
//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`).
//!
//! Scopes are Find in Files scopes; excluded roots are never analysed. Every
//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`.
use crate::Host;
use crate::Project;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use super::doc::blocking;
use ide_analysis::{
DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary,
};
use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId};
use ide_language::LanguageRegistry;
use ide_lsp::{FileInspection, InspectHost, InspectOptions};
use ide_search::{FileMask, SearchScope};
use ide_workspace::Documents;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
/// Files of `scope`, minus excluded roots and ignored names.
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
let mask = FileMask::new(&[], &project.search_excludes())?;
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
.into_iter()
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
.collect())
}
/// Current text of a file (the unsaved buffer when open) and its language.
fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> {
if let Some(doc) = project.documents.by_path(path) {
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, snap.text));
}
let loaded = ide_vfs::load_text(path).ok()?;
if loaded.binary || loaded.large || loaded.too_large {
return None;
}
let language = languages.detect(path, loaded.text.lines().next());
Some((language, loaded.text))
}
// ---- Inspect Code ----
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InspectedFile {
pub path: IpcPath,
pub rel_path: String,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InspectSummary {
/// Files inspected by a language server.
pub files: u32,
pub problems: u32,
/// Files in scope no language server serves.
pub skipped: u32,
/// Files whose server failed or never became ready.
pub failed: u32,
pub cancelled: bool,
pub elapsed_ms: u32,
}
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InspectEvent {
/// Files about to be inspected.
Started {
total: u32,
},
/// A file finished (with or without problems).
File {
file: InspectedFile,
done: u32,
},
Done {
summary: InspectSummary,
},
}
struct DocumentsHost(Arc<Documents>);
impl InspectHost for DocumentsHost {
fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> {
let was_open = self.0.by_path(path).is_some();
Ok((self.0.open(path)?.id, was_open))
}
fn close(&self, doc: DocId) {
self.0.close(doc);
}
}
// ---- Locate Duplicates ----
// ---- Structural Search ----
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StructuralEvent {
File { file: StructuralFile },
Done { summary: StructuralSummary },
}
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StructuralLanguage {
pub id: LanguageId,
pub name: String,
}
/// Inspect Code: diagnostics of every file in `scope` that a language server
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
pub async fn inspect_code(
host: &Host,
window: &str,
request: RequestId,
scope: SearchScope,
on_event: Channel<InspectEvent>,
) -> IdeResult<()> {
let started = Instant::now();
let project = host.project(window)?;
let cancel = host.begin_request(request);
let listed = {
let (project, cancel) = (project.clone(), cancel.clone());
blocking(move || files_in_scope(&project, &scope, &cancel)).await
};
let files = match listed {
Ok(files) => files,
Err(e) => {
host.end_request(request);
return Err(e);
}
};
let total_in_scope = files.len() as u32;
let rel: std::collections::HashMap<_, _> = files
.iter()
.filter(|f| project.lsp.serves_path(&f.path))
.map(|f| (f.path.clone(), f.rel_path.clone()))
.collect();
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
let mut paths: Vec<_> = rel.keys().cloned().collect();
paths.sort();
let tally = Mutex::new(summary.clone());
let done = std::sync::atomic::AtomicU32::new(0);
let docs = Arc::new(DocumentsHost(project.documents.clone()));
project
.lsp
.inspect_files(docs, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| {
let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let diagnostics = {
let mut t = tally.lock();
match outcome {
FileInspection::Inspected(items) => {
t.files += 1;
t.problems += items.len() as u32;
items
}
FileInspection::NoServer => {
t.skipped += 1;
Vec::new()
}
FileInspection::Failed(message) => {
tracing::debug!("inspect {}: {message}", path.display());
t.failed += 1;
Vec::new()
}
}
};
let rel_path = rel.get(&path).cloned().unwrap_or_default();
let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics };
let _ = on_event.send(InspectEvent::File { file, done });
})
.await;
host.end_request(request);
summary = tally.into_inner();
summary.cancelled = cancel.is_cancelled();
summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
let _ = on_event.send(InspectEvent::Done { summary });
Ok(())
}
/// Locate Duplicates over the files of `scope`.
pub async fn analysis_locate_duplicates(
host: &Host,
window: &str,
request: RequestId,
scope: SearchScope,
options: DuplicateOptions,
) -> IdeResult<DuplicatesReport> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let languages = project.languages.clone();
let syntax = host.syntax();
let result = blocking(move || {
let files = files_in_scope(&project, &scope, &cancel)?;
let loader = |p: &Path| load(&project, &languages, p);
Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel))
})
.await;
host.end_request(request);
result
}
/// Languages Search Structurally supports (those with a bundled grammar).
pub async fn structural_languages(host: &Host, _window: &str) -> IdeResult<Vec<StructuralLanguage>> {
let syntax = host.syntax();
let mut out: Vec<StructuralLanguage> = host
.languages
.all()
.into_iter()
.filter(|c| syntax.supports(&c.id))
.map(|c| StructuralLanguage { id: c.id, name: c.name })
.collect();
out.sort_by_key(|l| l.name.to_lowercase());
Ok(out)
}
/// Checks a structural search template (and language) while the user types.
pub async fn structural_validate(host: &Host, _window: &str, query: StructuralQuery) -> IdeResult<()> {
let syntax = host.syntax();
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
}
/// Search Structurally over `scope`; files with matches stream through
/// `on_event`, ending with `Done`. With a replacement, matches carry their
/// replacement text (applied by the UI as a workspace edit).
pub async fn structural_search(
host: &Host,
window: &str,
request: RequestId,
scope: SearchScope,
query: StructuralQuery,
on_event: Channel<StructuralEvent>,
) -> IdeResult<()> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let result = blocking(move || {
let files = files_in_scope(&project, &scope, &cancel)?;
let loader = |p: &Path| load(&project, &languages, p);
ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| {
let _ = events.send(StructuralEvent::File { file });
})
})
.await;
host.end_request(request);
let _ = on_event.send(StructuralEvent::Done { summary: result? });
Ok(())
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("inspect_code", |c| async move {
inspect_code(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.sender("onEvent")?).await
});
d.add("analysis_locate_duplicates", |c| async move {
analysis_locate_duplicates(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("options")?).await
});
d.add("structural_languages", |c| async move { structural_languages(&c.host, &c.window).await });
d.add("structural_validate", |c| async move { structural_validate(&c.host, &c.window, c.arg("query")?).await });
d.add("structural_search", |c| async move {
structural_search(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("query")?, c.sender("onEvent")?)
.await
});
}
+263
Переглянути файл
@@ -0,0 +1,263 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
}
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
/// when the project names no version).
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
let jdks = ide_sdk::java::installed_jdks();
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
}
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
let mvn = ide_sdk::apply::maven_command(root, settings);
let options = ide_sdk::apply::maven_options(root, settings);
for p in &mut m.projects {
p.env = java_env(jdk.as_ref(), p.java_version);
if let Some(mvn) = &mvn {
p.command = mvn.to_string_lossy().into_owned();
}
p.options = options.clone();
}
}
if let Some(g) = &mut model.gradle {
g.env = java_env(jdk.as_ref(), g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
Some(_) => None,
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
};
let explain = |error: &mut Option<String>, hint: Option<String>| {
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
*e = format!("{hint}\n{e}");
}
};
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = missing(g.java_version);
explain(&mut g.sync_error, hint);
}
model
}
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
/// Returns the refreshed model; a failed build is reported in its `syncError`.
pub async fn build_sync(
host: &Host,
window: &str,
tool: BuildTool,
request: RequestId,
offline: bool,
profiles: Vec<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
BuildTool::Maven => current
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
};
let options = SyncOptions {
offline,
profiles,
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
project: &Project,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
let text = project.text_of(&path)?;
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
return Ok((path, text, range));
}
}
Err(IdeError::not_found(format!(
"{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)"
)))
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
pub async fn maven_dependency_declaration(
host: &Host,
window: &str,
pom: String,
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
.map_err(join_err)?
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
pub async fn maven_exclude_dependency(
host: &Host,
window: &str,
pom: String,
dependency: ArtifactKey,
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit =
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
apply_workspace_edit(&project.documents, &edit)?;
}
Some(Exclusion::Present) => {}
None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))),
}
Ok(Location::file(path, range))
})
.await
.map_err(join_err)?
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
/// are cleared first; the new messages are published as `build` diagnostics.
pub async fn build_run(
host: &Host,
window: &str,
kind: BuildKind,
file: Option<std::path::PathBuf>,
request: RequestId,
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
let project = host.project(window)?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: compile::DIAGNOSTICS_SOURCE.into(),
items: Vec::new(),
});
}
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
for (path, items) in compile::diagnostics(&result.messages) {
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
}
Ok(result)
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
d.add("build_sync", |c| async move {
build_sync(
&c.host,
&c.window,
c.arg("tool")?,
c.arg("request")?,
c.arg("offline")?,
c.arg("profiles")?,
c.sender("onProgress")?,
)
.await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
d.add("maven_exclude_dependency", |c| async move {
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
});
d.add("build_run", |c| async move {
build_run(
&c.host,
&c.window,
c.arg("kind")?,
c.arg("file")?,
c.arg("request")?,
c.arg("clear")?,
c.sender("onEvent")?,
)
.await
});
}
+32
Переглянути файл
@@ -0,0 +1,32 @@
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
use crate::Host;
use crate::rpc::Dispatcher;
use crate::ops::doc::blocking;
use ide_core::IdeResult;
use ide_vfs::{ByteRange, FileProbe};
use std::path::PathBuf;
/// Size and text/binary classification, from the file's head only.
pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<FileProbe> {
blocking(move || ide_vfs::probe(&path)).await
}
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
blocking(move || ide_vfs::read_range(&path, offset, len)).await
}
/// An image file as a `data:` URL.
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await });
d.add("fs_read_range", |c| async move {
fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await
});
d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await });
}
+45
Переглянути файл
@@ -0,0 +1,45 @@
//! Run with Coverage: launch plans and coverage reports.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
use ide_run::RunConfig;
/// Output directories kept per kind (older runs' data is deleted).
pub const KEEP_RUNS: usize = 10;
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
let project = host.project(window)?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
Ok(ide_coverage::prepare(&root, &config, &out))
})
.await
}
/// Reads the report a coverage run left behind.
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// with paths mapped into this project.
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = host.project(window)?.root.clone();
blocking(move || ide_coverage::import(&path, &root)).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await });
d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await });
d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await });
}
+357
Переглянути файл
@@ -0,0 +1,357 @@
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DebugSessionId, IdeResult};
use ide_dap::*;
use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
let label = window;
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
let settings = match host.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
Some(path.to_string_lossy().into_owned())
};
host.debugger.start_with(config, owner, custom).await
}
pub async fn debug_set_breakpoints(
host: &Host,
_window: &str,
path: PathBuf,
breakpoints: Vec<Breakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
host.debugger.set_breakpoints(path, breakpoints).await
}
pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
host.debugger.threads(session).await
}
pub async fn debug_stack_trace(
host: &Host,
_window: &str,
session: DebugSessionId,
thread_id: i64,
) -> IdeResult<Vec<StackFrame>> {
host.debugger.stack_trace(session, thread_id).await
}
pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
host.debugger.scopes(session, frame_id).await
}
pub async fn debug_variables(
host: &Host,
_window: &str,
session: DebugSessionId,
reference: i64,
) -> IdeResult<Vec<Variable>> {
host.debugger.variables(session, reference).await
}
pub async fn debug_evaluate(
host: &Host,
_window: &str,
session: DebugSessionId,
frame_id: Option<i64>,
expression: String,
) -> IdeResult<Variable> {
host.debugger.evaluate(session, frame_id, expression).await
}
pub async fn debug_resume(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
host.debugger.resume(session, thread_id).await
}
pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
host.debugger.pause(session, thread_id).await
}
pub async fn debug_step(
host: &Host,
_window: &str,
session: DebugSessionId,
thread_id: i64,
kind: StepKind,
) -> IdeResult<()> {
host.debugger.step(session, thread_id, kind).await
}
pub async fn debug_run_to(
host: &Host,
_window: &str,
session: DebugSessionId,
thread_id: i64,
path: PathBuf,
line: u32,
force: bool,
) -> IdeResult<()> {
host.debugger.run_to(session, thread_id, path, line, force).await
}
pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.stop(session).await
}
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
pub async fn debug_evaluate_in(
host: &Host,
_window: &str,
session: DebugSessionId,
frame_id: Option<i64>,
expression: String,
context: Option<String>,
) -> IdeResult<Variable> {
host.debugger.evaluate_in(session, frame_id, expression, context).await
}
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
pub fn debug_breakpoint_states(host: &Host, _window: &str, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
Ok(host.debugger.breakpoint_states(&path))
}
/// This window's live sessions, including adapter-started child sessions.
pub fn debug_sessions(host: &Host, window: &str) -> IdeResult<Vec<SessionInfo>> {
Ok(host.debugger.sessions(Some(host.owner_window(window))))
}
pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult<Vec<SessionExceptionFilters>> {
Ok(host.debugger.exception_filters(Some(host.owner_window(window))))
}
pub async fn debug_set_exception_breakpoints(
host: &Host,
_window: &str,
choices: std::collections::BTreeMap<String, bool>,
) -> IdeResult<()> {
host.debugger.set_exception_breakpoints(choices).await
}
#[allow(clippy::too_many_arguments)]
pub async fn debug_set_variable(
host: &Host,
_window: &str,
session: DebugSessionId,
container: i64,
name: String,
evaluate_name: Option<String>,
frame_id: Option<i64>,
value: String,
) -> IdeResult<Variable> {
host.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
}
/// Force Step Over: step over with every breakpoint suspended until the step ends.
pub async fn debug_force_step_over(
host: &Host,
_window: &str,
session: DebugSessionId,
thread_id: i64,
) -> IdeResult<()> {
host.debugger.force_step_over(session, thread_id).await
}
/// Smart Step Into: the calls on the current line of `frame_id`.
pub async fn debug_step_in_targets(
host: &Host,
_window: &str,
session: DebugSessionId,
frame_id: i64,
) -> IdeResult<Vec<StepInTarget>> {
host.debugger.step_in_targets(session, frame_id).await
}
pub async fn debug_step_in_target(
host: &Host,
_window: &str,
session: DebugSessionId,
thread_id: i64,
target_id: i64,
) -> IdeResult<()> {
host.debugger.step_in_target(session, thread_id, target_id).await
}
/// Reset Frame (Drop Frame).
pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
host.debugger.restart_frame(session, frame_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
/// Method breakpoints (all files); states aligned with `breakpoints`.
pub async fn debug_set_function_breakpoints(
host: &Host,
_window: &str,
breakpoints: Vec<FunctionBreakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
host.debugger.set_function_breakpoints(breakpoints).await
}
/// Field watchpoints; states aligned with `breakpoints`.
pub async fn debug_set_data_breakpoints(
host: &Host,
_window: &str,
breakpoints: Vec<DataBreakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
host.debugger.set_data_breakpoints(breakpoints).await
}
pub async fn debug_data_breakpoint_info(
host: &Host,
_window: &str,
session: DebugSessionId,
container: i64,
name: String,
frame_id: Option<i64>,
) -> IdeResult<DataBreakpointInfo> {
host.debugger.data_breakpoint_info(session, container, name, frame_id).await
}
/// Attach to Process: the user's processes a debugger can attach to.
pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResult<Vec<attach::AttachableProcess>> {
tokio::task::spawn_blocking(attach::attachable_processes)
.await
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
host: &Host,
_window: &str,
request: ide_core::RequestId,
jvm_host: String,
port: u16,
) -> IdeResult<u16> {
let token = host.begin_request(request);
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
host.end_request(request);
result
}
/// HotSwap (Reload Changed Classes) in a java-debug session.
pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<HotSwapResult> {
host.debugger.redefine_classes(session).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
d.add("debug_set_breakpoints", |c| async move {
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
});
d.add("debug_threads", |c| async move { debug_threads(&c.host, &c.window, c.arg("session")?).await });
d.add("debug_stack_trace", |c| async move {
debug_stack_trace(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
});
d.add(
"debug_scopes",
|c| async move { debug_scopes(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await },
);
d.add("debug_variables", |c| async move {
debug_variables(&c.host, &c.window, c.arg("session")?, c.arg("reference")?).await
});
d.add("debug_evaluate", |c| async move {
debug_evaluate(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?, c.arg("expression")?).await
});
d.add(
"debug_resume",
|c| async move { debug_resume(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add(
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
d.add("debug_run_to", |c| async move {
debug_run_to(
&c.host,
&c.window,
c.arg("session")?,
c.arg("threadId")?,
c.arg("path")?,
c.arg("line")?,
c.arg("force")?,
)
.await
});
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
d.add("debug_evaluate_in", |c| async move {
debug_evaluate_in(
&c.host,
&c.window,
c.arg("session")?,
c.arg("frameId")?,
c.arg("expression")?,
c.arg("context")?,
)
.await
});
d.add("debug_breakpoint_states", |c| async move { debug_breakpoint_states(&c.host, &c.window, c.arg("path")?) });
d.add("debug_sessions", |c| async move { debug_sessions(&c.host, &c.window) });
d.add("debug_exception_filters", |c| async move { debug_exception_filters(&c.host, &c.window) });
d.add("debug_set_exception_breakpoints", |c| async move {
debug_set_exception_breakpoints(&c.host, &c.window, c.arg("choices")?).await
});
d.add("debug_set_variable", |c| async move {
debug_set_variable(
&c.host,
&c.window,
c.arg("session")?,
c.arg("container")?,
c.arg("name")?,
c.arg("evaluateName")?,
c.arg("frameId")?,
c.arg("value")?,
)
.await
});
d.add("debug_force_step_over", |c| async move {
debug_force_step_over(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
});
d.add("debug_step_in_targets", |c| async move {
debug_step_in_targets(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_step_in_target", |c| async move {
debug_step_in_target(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("targetId")?).await
});
d.add("debug_restart_frame", |c| async move {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
d.add("debug_set_data_breakpoints", |c| async move {
debug_set_data_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
d.add("debug_data_breakpoint_info", |c| async move {
debug_data_breakpoint_info(
&c.host,
&c.window,
c.arg("session")?,
c.arg("container")?,
c.arg("name")?,
c.arg("frameId")?,
)
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
d.add(
"debug_redefine_classes",
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
);
}
+42
Переглянути файл
@@ -0,0 +1,42 @@
use crate::Host;
use ide_core::{ChangeSet, DocId, IdeResult};
use ide_workspace::DocumentInfo;
use std::path::PathBuf;
pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || {
let info = project.documents.open(&path)?;
project.index.mark_recent(&path);
Ok(info)
})
.await
}
pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
host.project(window)?.documents.close(doc);
Ok(())
}
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
host.project(window)?.documents.edit(doc, base_version, changes)
}
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save(doc)).await
}
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save_all()).await
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.snapshot(doc)).await
}
mod commands;
pub use commands::*;
+125
Переглянути файл
@@ -0,0 +1,125 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
use ide_language::{FoldRange, SyntaxHighlights};
use ide_text::LineEnding;
use ide_workspace::{DocumentInfo, HistoryEntry};
use std::path::PathBuf;
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
/// keeping it off the main thread and the async runtime's workers.
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// 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?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
}
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
host.project(window)?.documents.set_line_ending(doc, line_ending)
}
/// Structure view: LSP document symbols when available (with tree-sitter
/// visibility), tree-sitter otherwise.
/// Tree-sitter answers come from the per-document tree cache (incremental
/// re-parse); large documents get no syntax structure.
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = host.project(window)?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
Err(e) => return lsp.ok_or(e),
};
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
Ok(match lsp {
// Server symbols carry no visibility; borrow it from the syntax tree.
Some(mut items) => {
if !snap.large {
ide_language::merge_visibility(&mut items, &syntax());
}
items
}
None if snap.large => Vec::new(),
None => syntax(),
})
})
.await
}
pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FoldRange>> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
})
.await
}
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
/// empty when the grammar has no highlights query or the document is large.
pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<SyntaxHighlights> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(SyntaxHighlights::default());
}
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
})
.await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
/// Large documents fall back to word / line / document ranges.
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
.await
}
pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
host.project(window)?.history.list(&path)
}
pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<String> {
host.project(window)?.history.content(&id)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
});
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_selection_ranges", |c| async move {
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
});
d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) });
d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) });
}
+85
Переглянути файл
@@ -0,0 +1,85 @@
use crate::Host;
use ide_core::IdeResult;
use ide_vfs::FileEntry;
use std::path::PathBuf;
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = host
.project(window)
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
}
None => globs,
}
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
}
/// Creates an empty file; inside a project, honours the EditorConfig
/// `charset` (writes the BOM for `utf-8-bom` / UTF-16).
pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
match host.project(window) {
Ok(p) => {
let defaults = p.documents.file_defaults();
if defaults.editorconfig {
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
} else {
ide_vfs::create_file(&path)?;
}
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
let bom = defaults.new_file_bytes();
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
/// state). Returns whether the file is read-only now.
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
match host.project(window) {
Ok(p) => p.documents.set_writable(&path, writable),
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
}
}
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
if let Some(h) = history {
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<String> {
let project = host.project(window).ok();
host.blocking(move || match project {
Some(p) => p.text_of(&path),
None => Ok(ide_vfs::load_text(&path)?.text),
})
.await
}
+55
Переглянути файл
@@ -0,0 +1,55 @@
//! Local History beyond single files: folders, labels, restoring deleted files.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::IdeResult;
use ide_workspace::DirectoryHistory;
use std::path::PathBuf;
use super::doc::blocking;
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<DirectoryHistory> {
let project = host.project(window)?;
blocking(move || project.history.list_dir(&path)).await
}
/// Local History › Put Label…: a named point in the history of every file.
pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> {
let project = host.project(window)?;
project.history.put_label(&project.root, &label)
}
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
/// file and its folders); an open editor updates in place.
pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
let text = project.history.content(&id)?;
if let Some(doc) = project.documents.by_path(&path) {
project.documents.replace_all(doc, &text)?;
return project.documents.save(doc);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if !path.exists() {
ide_vfs::create_file(&path)?;
}
let doc = project.documents.open(&path)?.id;
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
project.documents.close(doc);
result
})
.await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await });
d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) });
d.add(
"history_restore",
|c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await },
);
}
+95
Переглянути файл
@@ -0,0 +1,95 @@
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
use crate::Host;
use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_lsp::jdtls::JavaLaunchInfo;
use std::path::PathBuf;
/// Builds the workspace (when `build`), then resolves the runtime (or test)
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
pub async fn java_resolve_launch(
host: &Host,
window: &str,
module: PathBuf,
test: bool,
build: bool,
) -> IdeResult<JavaLaunchInfo> {
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
let built = if build && compile::has_non_java_sources(&module) {
let (root, cache, module, settings) =
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
tokio::task::spawn_blocking(move || {
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
} else {
Vec::new()
};
let mut info = project.lsp.java_launch(&module, test, build).await?;
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
Ok(info)
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_debug_server(&module).await
}
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
pub async fn java_build_workspace(
host: &Host,
window: &str,
module: PathBuf,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
+363
Переглянути файл
@@ -0,0 +1,363 @@
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
pub async fn lsp_completion(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger, cancel).await;
host.end_request(request);
result
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
pub async fn lsp_inline_completion(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
automatic: bool,
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_resolve_completion(
host: &Host,
window: &str,
doc: DocId,
item: CompletionItem,
) -> IdeResult<CompletionItem> {
host.project(window)?.lsp.resolve_completion(doc, item).await
}
pub async fn lsp_hover(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
host.project(window)?.lsp.signature_help(doc, pos).await
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
host.project(window)?.lsp.goto(doc, pos, kind).await
}
/// Implementations of the symbols at `positions`, one list per position (the
/// editor's "is implemented / overridden" gutter markers). Cancel with
/// `cancel_request(request)`.
pub async fn lsp_implementations(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
host.project(window)?.lsp.virtual_document(&uri).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
pub async fn lsp_references(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
host.project(window)?.lsp.document_highlights(doc, pos).await
}
pub async fn lsp_workspace_symbols(
host: &Host,
window: &str,
request: RequestId,
query: String,
) -> IdeResult<Vec<WorkspaceSymbol>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.workspace_symbols(&query, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_code_actions(
host: &Host,
window: &str,
doc: DocId,
range: Range,
only: Vec<String>,
) -> IdeResult<Vec<CodeAction>> {
host.project(window)?.lsp.code_actions(doc, range, only).await
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
range: Range,
only: Vec<String>,
) -> IdeResult<CodeActionQuery> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
result
}
/// Resolves a code action's edit without applying it (intention preview).
pub async fn lsp_resolve_code_action(
host: &Host,
window: &str,
doc: DocId,
action: CodeAction,
) -> IdeResult<CodeAction> {
if action.edit.is_some() {
return Ok(action);
}
host.project(window)?.lsp.resolve_code_action(doc, action).await
}
/// Resolves (if needed), applies the edit, then runs the command of a code action.
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
let project = host.project(window)?;
let action = if action.edit.is_none() && action.command.is_none() {
project.lsp.resolve_code_action(doc, action).await?
} else {
action
};
if let Some(edit) = &action.edit {
apply_edit(&project.documents, edit)?;
}
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
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
}
/// Computes the rename edit without applying it, so the UI can preview conflicts.
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
host.project(window)?.lsp.rename(doc, pos, new_name).await
}
pub async fn lsp_format(
host: &Host,
window: &str,
doc: DocId,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
host.project(window)?.lsp.format(doc, range, options).await
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
host.project(window)?.lsp.inlay_hints(doc, range).await
}
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
host.project(window)?.lsp.semantic_tokens(doc).await
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
host.project(window)?.lsp.code_lens(doc).await
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
host.project(window)?.lsp.document_links(doc).await
}
pub async fn lsp_call_hierarchy(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
direction: HierarchyDirection,
) -> IdeResult<Vec<HierarchyItem>> {
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
}
pub async fn lsp_type_hierarchy(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
direction: HierarchyDirection,
) -> IdeResult<Vec<HierarchyItem>> {
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
}
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
Ok(host.project(window)?.lsp.servers())
}
pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
host.project(window)?.lsp.resend_diagnostics();
Ok(())
}
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
host.project(window)?.lsp.restart(server).await
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
}
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
host.project(window)?.lsp.execute_command(doc, command).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_completion", |c| async move {
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
});
d.add("lsp_inline_completion", |c| async move {
lsp_inline_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("automatic")?)
.await
});
d.add("lsp_resolve_completion", |c| async move {
lsp_resolve_completion(&c.host, &c.window, c.arg("doc")?, c.arg("item")?).await
});
d.add("lsp_hover", |c| async move {
lsp_hover(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_signature_help", |c| async move {
lsp_signature_help(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add(
"lsp_goto",
|c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await },
);
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
lsp_references(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("pos")?,
c.arg("includeDeclaration")?,
c.arg("projectWide")?,
)
.await
});
d.add("lsp_document_highlights", |c| async move {
lsp_document_highlights(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_workspace_symbols", |c| async move {
lsp_workspace_symbols(&c.host, &c.window, c.arg("request")?, c.arg("query")?).await
});
d.add("lsp_code_actions", |c| async move {
lsp_code_actions(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("only")?).await
});
d.add("lsp_code_action_query", |c| async move {
lsp_code_action_query(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("range")?, c.arg("only")?)
.await
});
d.add("lsp_resolve_code_action", |c| async move {
lsp_resolve_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
});
d.add("lsp_apply_code_action", |c| async move {
lsp_apply_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
});
d.add("lsp_prepare_rename", |c| async move {
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_rename", |c| async move {
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
});
d.add("lsp_format", |c| async move {
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
});
d.add(
"lsp_inlay_hints",
|c| async move { lsp_inlay_hints(&c.host, &c.window, c.arg("doc")?, c.arg("range")?).await },
);
d.add("lsp_semantic_tokens", |c| async move { lsp_semantic_tokens(&c.host, &c.window, c.arg("doc")?).await });
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
d.add("lsp_call_hierarchy", |c| async move {
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
});
d.add("lsp_type_hierarchy", |c| async move {
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
});
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
d.add("lsp_execute_command", |c| async move {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
}
+85
Переглянути файл
@@ -0,0 +1,85 @@
//! Command implementations shared by the desktop shell's IPC commands and the
//! remote server ([`crate::rpc`]): each takes the [`Host`], the calling window
//! and the command's arguments, and streams through [`Emit`] callbacks.
pub mod agent;
pub mod analysis;
pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod doc;
pub mod fs;
pub mod history;
pub mod java;
pub mod lsp;
pub mod nav;
pub mod project;
pub mod refactor;
pub mod run;
pub mod sdk;
pub mod search;
pub mod spell;
pub mod syntax;
pub mod term;
pub mod tools;
pub mod vcs;
use crate::Host;
use ide_core::{IdeError, IdeResult};
use std::sync::Arc;
/// A stream of values to the caller (a Tauri channel, or channel notifications).
pub type Emit<T> = Arc<dyn Fn(T) + Send + Sync>;
/// A streaming argument: what the desktop shell passes as a Tauri channel and
/// the server as `channel` notifications.
pub struct Channel<T>(Emit<T>);
impl<T> Channel<T> {
pub fn new(send: impl Fn(T) + Send + Sync + 'static) -> Self {
Self(Arc::new(send))
}
/// Sends one value; never fails (a closed receiver drops it).
pub fn send(&self, value: T) -> IdeResult<()> {
(self.0)(value);
Ok(())
}
}
impl<T> Clone for Channel<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
/// Registers the commands of every module with the remote server.
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
agent::register(d);
lsp::register(d);
nav::register(d);
refactor::register(d);
analysis::register(d);
syntax::register(d);
spell::register(d);
history::register(d);
coverage::register(d);
build::register(d);
tools::register(d);
sdk::register(d);
java::register(d);
debug::register(d);
bytes::register(d);
vcs::register(d);
doc::register(d);
search::register(d);
run::register(d);
}
impl Host {
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool.
pub async fn blocking<T: Send + 'static>(&self, f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
+176
Переглянути файл
@@ -0,0 +1,176 @@
use super::doc::blocking;
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_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// A web reference no language server resolves and where it points: `class="card"` →
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
/// `--x:` declarations.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebGoto {
pub reference: WebReference,
pub targets: Vec<Location>,
}
const MAX_WEB_TARGETS: usize = 200;
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
(project.root.clone(), resources)
}
pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
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))
}
pub fn find_symbols(
host: &Host,
window: &str,
query: String,
filter: SymbolFilter,
limit: u32,
) -> IdeResult<Vec<SymbolMatch>> {
Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize))
}
pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
host.project(window)?.index.mark_recent(&path);
Ok(())
}
pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
host.project(window)?.index.remove_recent(&path);
Ok(())
}
/// Recently opened files, most recent first (⌘E).
pub fn recent_files(host: &Host, window: &str) -> IdeResult<Vec<ide_core::IpcPath>> {
Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect())
}
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large {
return Ok(None);
}
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
let here = |range| Location { path: snap.path.clone(), range, uri: None };
let targets = match reference.symbol() {
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
Some((name, kind)) => {
// The open document from its (possibly unsaved) text, other files from the index.
let mut targets: Vec<Location> = syntax
.web_definitions(&snap.language, &snap.text)
.into_iter()
.filter(|t| t.name == name && t.kind == kind)
.map(|t| here(t.range))
.collect();
targets.extend(
project
.index
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
.into_iter()
.filter(|m| *m.path != *snap.path)
.map(|m| Location { path: m.path, range: m.range, uri: None }),
);
targets
}
};
Ok(Some(WebGoto { reference, targets }))
})
.await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
window: &str,
from: IpcPath,
value: String,
) -> IdeResult<Option<IpcPath>> {
let project = host.project(window).ok();
blocking(move || {
let (root, resources) = project.as_deref().map(reference_roots).unzip();
let resources = resources.unwrap_or_default();
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
})
.await
}
/// Files and folders that continue `prefix`, a path being typed in `from`.
pub async fn path_completions(
host: &Host,
window: &str,
from: IpcPath,
prefix: String,
) -> IdeResult<Vec<PathCompletion>> {
let project = host.project(window).ok();
blocking(move || {
let (root, resources) = project.as_deref().map(reference_roots).unzip();
let resources = resources.unwrap_or_default();
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
})
.await
}
/// Paths in an HTML document or stylesheet that name no existing file.
pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FileReference>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
let (root, resources) = reference_roots(&project);
Ok(syntax
.web_file_references(&snap.language, &snap.text)
.into_iter()
.filter(|r| {
file_refs::is_file_reference(&r.value)
&& !file_refs::path_part(&r.value).is_empty()
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
})
.collect())
})
.await
}
/// 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("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
});
d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) });
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
d.add("path_completions", |c| async move {
path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await
});
d.add("web_unresolved_references", |c| async move {
web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await
});
}
+135
Переглянути файл
@@ -0,0 +1,135 @@
use crate::{Host, Project};
use ide_core::IdeResult;
use ide_core::settings::Settings;
use ide_workspace::settings::SettingsScope;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
use std::sync::Arc;
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInfo {
pub root: ide_core::IpcPath,
pub name: String,
pub is_git: bool,
pub settings: Settings,
}
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
ProjectInfo {
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
}
}
/// Opens `path` in `window` (off the async workers: it touches the disk).
pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<Project>> {
let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf());
host.blocking(move || h.open_project(&window, &path)).await
}
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
host.project(window).ok().map(|p| info(&p))
}
/// Effective settings for the window's project, or global settings without one.
pub fn settings(host: &Host, window: &str) -> Settings {
match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
}
}
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
Ok(host.project(window)?.roots())
}
/// What [`set_settings`] changed; the caller tells the windows.
pub struct SettingsApplied {
pub settings: Settings,
/// A project value (else a global one).
pub project_scope: bool,
/// The project's root, when its content roots changed.
pub roots_changed: Option<ide_core::IpcPath>,
}
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
/// project or globally, and applies it to the open projects: file defaults,
/// file types, language servers, index scope and SSH configurations.
pub fn set_settings(
host: &Host,
window: &str,
scope: SettingsScope,
key: &str,
value: &str,
) -> IdeResult<SettingsApplied> {
let value: serde_json::Value =
serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let current = host.project(window).ok();
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
host.global_settings.set(SettingsScope::Global, key, value)?;
p.settings.reload()
}
None => host.global_settings.set(SettingsScope::Global, key, value)?,
};
if let Some(p) = &current {
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
}
// A global value reaches every other open project too.
if !project_scope {
for project in host.projects() {
if current.as_ref().is_some_and(|c| c.root == project.root) {
continue;
}
let effective = project.settings.reload();
project
.documents
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
}
}
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.apply_file_types(&project.settings.effective());
}
});
}
if key == "ssh" || key.starts_with("ssh.") {
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
for project in affected() {
let lsp = project.lsp.clone();
host.runtime.spawn(async move { lsp.reconfigure().await });
}
}
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.refresh_index_scope();
}
});
}
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
Ok(SettingsApplied { settings, project_scope, roots_changed })
}
+112
Переглянути файл
@@ -0,0 +1,112 @@
//! File move / rename refactoring: reference updates from the language
//! servers, the move itself (one undoable step) and its undo.
use crate::Host;
use crate::apply_workspace_edit;
use crate::rpc::Dispatcher;
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
use ide_lsp::types::FileRenameEdits;
use ide_workspace::{FileMove, UndoOutcome};
use std::path::PathBuf;
use super::doc::blocking;
fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> {
moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect()
}
/// `workspace/willRenameFiles` on every interested server (before the move). Cancellable.
pub async fn refactor_file_move_edits(
host: &Host,
window: &str,
request: RequestId,
moves: Vec<FileMove>,
) -> IdeResult<FileRenameEdits> {
let project = host.project(window)?;
let token = host.begin_request(request);
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
host.end_request(request);
result
}
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
/// files and notifies the servers. Returns the id for [`refactor_undo_file_move`].
pub async fn refactor_move_files(
host: &Host,
window: &str,
moves: Vec<FileMove>,
edit: WorkspaceEdit,
label: String,
) -> IdeResult<u32> {
let project = host.project(window)?;
let p = project.clone();
let moved = moves.clone();
let id = blocking(move || {
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
})
.await?;
project.lsp.did_rename_files(&pairs(&moves));
Ok(id)
}
/// Undoes a file move: moves back and reverts the reference updates.
pub async fn refactor_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<UndoOutcome> {
let project = host.project(window)?;
let p = project.clone();
let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?;
project.lsp.did_rename_files(&pairs(&outcome.moves));
Ok(outcome)
}
/// Whether the file move `id` can still be undone.
pub fn refactor_can_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<bool> {
Ok(host.project(window)?.moves.can_undo(id))
}
/// "Search in comments and strings": textual occurrences of the moved files'
/// names, qualified names and paths (before the move). Cancellable.
pub async fn refactor_file_move_text_edits(
host: &Host,
window: &str,
request: RequestId,
moves: Vec<FileMove>,
) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
let token = host.begin_request(request);
let cancel = token.clone();
let result = blocking(move || {
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
let edit = ide_workspace::text_refs::find_text_occurrences(
&project.root,
&project.exclude_globs(),
&replacements,
&|| cancel.is_cancelled(),
);
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
})
.await;
host.end_request(request);
result
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("refactor_file_move_edits", |c| async move {
refactor_file_move_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
});
d.add("refactor_move_files", |c| async move {
refactor_move_files(&c.host, &c.window, c.arg("moves")?, c.arg("edit")?, c.arg("label")?).await
});
d.add(
"refactor_undo_file_move",
|c| async move { refactor_undo_file_move(&c.host, &c.window, c.arg("id")?).await },
);
d.add(
"refactor_can_undo_file_move",
|c| async move { refactor_can_undo_file_move(&c.host, &c.window, c.arg("id")?) },
);
d.add("refactor_file_move_text_edits", |c| async move {
refactor_file_move_text_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
});
}
+91
Переглянути файл
@@ -0,0 +1,91 @@
use super::Emit;
use crate::Host;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
/// Detected configs followed by user-defined ones (user ids win on conflict).
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let project = host.project(window)?;
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let mut all: Vec<RunConfig> =
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
all.extend(user);
// Project interpreter / package manager / toolchains from Languages & Frameworks.
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
Ok(all)
}
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
ide_run::save_user_configs(&host.project(window)?.root, configs)
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
let project = host.project(window)?;
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH.
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let out = on_output.clone();
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let settings = project.settings.effective();
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
let mut config = config;
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
let encoding = settings.console.encoding;
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
};
let grace = Duration::from_millis(u64::from(settings.run.termination_timeout_ms));
if host.remote.runs.contains(id) {
return host.remote.runs.stop(id, grace);
}
host.processes.stop_with_grace(id, grace)
}
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data);
}
host.processes.write_stdin(id, data)
}
pub fn resize(host: &Host, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
if host.remote.runs.contains(id) {
return host.remote.runs.resize(id, cols, rows);
}
host.processes.resize(id, cols, rows)
}
pub fn close_stdin(host: &Host, id: RunId) -> IdeResult<()> {
if host.remote.runs.contains(id) {
return host.remote.runs.close_stdin(id);
}
host.processes.close_stdin(id)
}
pub fn list(host: &Host) -> Vec<RunInfo> {
let mut runs = host.processes.list();
runs.extend(host.remote.runs.list());
runs
}
mod commands;
pub use commands::*;
+48
Переглянути файл
@@ -0,0 +1,48 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
/// Configuration templates: per-type defaults applied to new configurations.
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let root = host.project(window)?.root.clone();
ide_run::load_templates(&root)
}
pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>) -> IdeResult<()> {
let root = host.project(window)?.root.clone();
ide_run::save_templates(&root, &templates)
}
/// The environment runs inherit when "Include system environment variables" is on.
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
ide_run::system_env()
}
/// 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(
host: &Host,
window: &str,
module: Option<std::path::PathBuf>,
since_ms: f64,
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
let root = host.project(window)?.root.clone();
let dir = module.map(|m| root.join(m)).unwrap_or(root);
let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64);
tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since))
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
}
+170
Переглянути файл
@@ -0,0 +1,170 @@
//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages.
use crate::Host;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::python::PythonPackage;
use ide_sdk::{Sdk, SdkKind};
use std::path::PathBuf;
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
/// Executables found for "run this file", and where single files are compiled to.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LocatedTools {
pub tools: Vec<ide_sdk::locate::LocatedTool>,
pub compile_dir: ide_core::IpcPath,
}
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// 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>> {
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
pub async fn sdk_locate_tools(
_host: &Host,
_window: &str,
names: Vec<String>,
near: Option<PathBuf>,
) -> IdeResult<LocatedTools> {
tokio::task::spawn_blocking(move || LocatedTools {
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
compile_dir: ide_sdk::locate::compile_dir().into(),
})
.await
.map_err(join_err)
}
/// `base -m venv location`; output lines stream through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
_window: &str,
base: PathBuf,
location: PathBuf,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<Sdk> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
})
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
pub async fn sdk_python_packages(
host: &Host,
_window: &str,
python: PathBuf,
request: RequestId,
) -> IdeResult<Vec<PythonPackage>> {
let cancel = host.begin_request(request);
let result =
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`.
#[allow(clippy::too_many_arguments)]
pub async fn sdk_python_pip(
host: &Host,
_window: &str,
python: PathBuf,
requirement: String,
uninstall: bool,
upgrade: bool,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<()> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
let _ = on_output.send(line);
};
if uninstall {
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
} else {
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
}
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Commands of this module the remote server answers.
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_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
});
d.add("sdk_python_create_venv", |c| async move {
sdk_python_create_venv(
&c.host,
&c.window,
c.arg("base")?,
c.arg("location")?,
c.arg("request")?,
c.sender("onOutput")?,
)
.await
});
d.add("sdk_python_packages", |c| async move {
sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await
});
d.add("sdk_python_pip", |c| async move {
sdk_python_pip(
&c.host,
&c.window,
c.arg("python")?,
c.arg("requirement")?,
c.arg("uninstall")?,
c.arg("upgrade")?,
c.arg("request")?,
c.sender("onOutput")?,
)
.await
});
}
+97
Переглянути файл
@@ -0,0 +1,97 @@
use super::Emit;
use crate::Host;
use ide_core::{IdeResult, RequestId};
use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum SearchEvent {
File { file: FileMatches },
Done { summary: SearchSummary },
}
pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange {
let kind = match l.kind {
ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment,
ide_language::LiteralKind::String => ide_search::LiteralKind::String,
};
ide_search::LiteralRange { start: l.start, end: l.end, kind }
}
/// Find in Files. Results stream through `on_event`; ends with `Done`.
/// Cancel with `cancel_request(request)`.
pub async fn start(
host: &Host,
window: &str,
request: RequestId,
mut query: SearchQuery,
on_event: Emit<SearchEvent>,
) -> IdeResult<()> {
let project = host.project(window)?;
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
// ignored names are never searched.
query.exclude.extend(project.search_excludes());
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let cancel = host.begin_request(request);
let documents = project.documents.clone();
let root = project.root.clone();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
project.content_roots()
}
_ => Vec::new(),
};
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let summary = host
.blocking(move || {
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
Some(ProjectFileSet::All) | None => true,
};
// Comments and string literals for the search context filter.
let literals = |path: &Path, text: &str| {
let language = languages.detect(path, text.lines().next());
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.text_of(p),
&cancel,
&|file| events(SearchEvent::File { file }),
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
})
.await;
host.end_request(request);
on_event(SearchEvent::Done { summary: summary? });
Ok(())
}
mod commands;
pub use commands::*;
+133
Переглянути файл
@@ -0,0 +1,133 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeResult, Pos, RequestId, TextEdit};
use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery};
use ide_workspace::Documents;
use std::path::{Path, PathBuf};
struct DocumentsHost<'a>(&'a Documents);
impl ReplaceHost for DocumentsHost<'_> {
fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult<Vec<TextEdit>>) -> IdeResult<u32> {
let docs = self.0;
let (doc, transient) = match docs.by_path(path) {
Some(doc) => (doc, false),
None => (docs.open(path)?.id, true),
};
let result = (|| {
// Recompute if the user typed between reading the text and applying.
for _ in 0..3 {
let snap = docs.text_snapshot(doc)?;
let edits = compute(&snap.text)?;
if edits.is_empty() {
return Ok(0);
}
if docs.text_snapshot(doc)?.version != snap.version {
continue;
}
docs.apply_edits(doc, &edits)?;
if transient {
docs.save(doc)?;
}
return Ok(edits.len() as u32);
}
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
})();
if transient {
docs.close(doc);
}
result
}
}
/// Replace in Files. Runs off the command thread; progress streams through
/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`.
///
/// Edits go through the document layer as one patch per file, so open editors
/// update live and undo the whole file's replacements in one step. Unsaved
/// editor buffers are replaced in place and left unsaved; files that were not
/// open are opened transiently, saved and closed.
#[allow(clippy::too_many_arguments)]
pub async fn replace_in_files(
host: &Host,
window: &str,
request: RequestId,
query: SearchQuery,
replacement: String,
preserve_case: bool,
targets: Vec<ReplaceTarget>,
on_event: Channel<ReplaceEvent>,
) -> IdeResult<()> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let events = on_event.clone();
let summary = tokio::task::spawn_blocking(move || {
let host = DocumentsHost(&project.documents);
ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| {
let _ = events.send(e);
})
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()));
host.end_request(request);
let summary = summary??;
let _ = on_event.send(ReplaceEvent::Done { summary });
Ok(())
}
/// Before / after text of `path` for Replace › Preview (unsaved buffer included).
pub async fn replace_preview(
host: &Host,
window: &str,
query: SearchQuery,
replacement: String,
preserve_case: bool,
path: PathBuf,
only: Option<Vec<Pos>>,
) -> IdeResult<ReplacePreview> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let text = project.text_of(&path)?;
ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref())
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
}
/// Validates a query (e.g. regex syntax) while the user types.
pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> {
ide_search::validate_query(&query)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("replace_in_files", |c| async move {
replace_in_files(
&c.host,
&c.window,
c.arg("request")?,
c.arg("query")?,
c.arg("replacement")?,
c.arg("preserveCase")?,
c.arg("targets")?,
c.sender("onEvent")?,
)
.await
});
d.add("replace_preview", |c| async move {
replace_preview(
&c.host,
&c.window,
c.arg("query")?,
c.arg("replacement")?,
c.arg("preserveCase")?,
c.arg("path")?,
c.arg("only")?,
)
.await
});
d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) });
}
Loaded 100 of 1447 files, more files were not shown because too many files have changed in this diff. Show more