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

..
Автор SHA1 Повідомлення Дата
Mr0Bread 41951e2895 refactor(platform): move choosePath out of settings (#3594)
choosePath, a thin wrapper over the platform file/folder dialog, lived in features/settings/languages/api.ts, so vcs/RevisionBrowser imported the settings feature to open a folder picker. It moves to platform/choosePath.ts unchanged (the dialog module still loads lazily). No behaviour change.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 04:15:46 +08:00
Sameh Elhawaryтаsamehelhawary feb261d172 feat(plugins): requests from a plugin to a running language server (API 1.9) (#3588)
Adds the language-servers import (request / notify, JSON strings, a borrow<project> handle) and the lsp:<server> capability, one grant per server name, high risk, API 1.9. Only the server's own namespace passes: initialize, initialized, shutdown, exit, $/ methods and every LSP namespace (textDocument, workspace, window, …) are refused, in the plugin host and again in LspManager. server is the name of a [[language-servers]] entry matched against what runs for the project; a server that isn't running is not-found and nothing is started. Requests honour the invocation's cancellation and I/O time budget, need a trusted project, and are limited to 256 bytes of method and 1 MiB of params. SDK helpers and mock support, a fixture plugin for the grant checks, docs, and rebuilt components.

Part of #3542

Co-authored-by: samehelhawary <[email protected]>
2026-11-16 03:47:57 +08:00
Mr0Bread 3d4a1fd387 refactor(ui): move iconNode out of editor/lsp (#3593)
iconNode only depends on ui/icons but lived in editor/lsp/iconMarkup.ts, so agents, database, debugger and viewers imported editor internals to draw an icon. It moves to ui/iconNode.ts. No behaviour change.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 03:33:25 +08:00
Mr0Bread 88e4b8f79b refactor(ui): stop ui and platform importing upward (#3591)
platform/filePresentation and platform/projectType imported the icon set from ui/, and ui/logColors imported LEVELS from editor/core/cm/log. filePresentation and projectType move to ui/, and the log level vocabulary moves to platform/logLevels, imported by both the log language and ui/logColors. No behaviour change.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 03:32:43 +08:00
Sameh Elhawaryтаsamehelhawary 3c9ac70cdf docs: format the Turkish README (#3592)
oxfmt --check failed on README.tr.md (added in #3519), stopping the Frontend CI job at format:check. Only the benchmark table's column alignment changes.

Co-authored-by: samehelhawary <[email protected]>
2026-11-16 03:31:42 +08:00
Vadim LazukтаInf1mum cc72161f92 feat(settings): point at community themes and notify one-dark users (#3574)
Appearance gets a Get more themes link beside the Theme selector that opens Settings › Plugins on the marketplace's themes (plugins#themes; pages read the target through SettingsPageFocusContext). Installs whose settings file still names the removed one-dark theme get a one-time notice that One Dark is now provided by the One Dark Pro plugin, with Install while it isn't installed; settings_removed_theme reports the raw id a settings file names before it is mapped to its stand-in.

Closes #3284

Co-authored-by: Inf1mum <[email protected]>
2026-11-16 03:31:23 +08:00
Mr0Bread 485638cba5 fix(agent): keep a model's effort levels when an idle task's settings change (#3590)
With no session running, switching a task's model drops its effort option until an agent reports the new model's levels. Changing another setting, or picking the same model again, then saved that effort-less copy as the model's levels, erasing them until a session reported them again; apply_late_choices could do the same after a model switch whose reply had no configOptions. A model's levels are now saved from the task's own copy only while it still holds per-model options.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 03:25:44 +08:00
Akbar Husain e968801415 feat(templates): live, surround, postfix and file templates for Zig (#3586)
Adds Zig to the template sets: 19 live templates (functions, main, test, struct / enum / union, if / while / for with captures, defer, try / catch, switch, std.debug.print), 9 surrounders, 11 postfix templates plus .not with a Zig tab in Settings, and a Zig File new-file template (empty, main, test, struct) with the Zig Test Function fragment. if / while conditions are parenthesised as Zig requires, and ! negation is offered as in the other brace languages.

Co-authored-by: akbar1214 <[email protected]>
2026-11-16 02:39:41 +08:00
Deng Liuтаdengliu 7b6962001b feat(agents): two-page model picker with pinned models, segmented effort, Reset and ACP cost badges (#3583)
The composer model picker becomes a two-page popover for every agent: page 1 lists models with search, a Pinned section, usage multiplier and description, and a summary bar for the active model; page 2 configures a model with Back, Reset to defaults, Pin, a segmented effort control and the cost category / multiplier from ACP _meta (copilotPriceCategory, copilotUsage) where present. Option defaults are kept across same-model updates for Reset. A contextTier / context_tier option is handled as per-model like effort. Agents with no effort, model options or cost metadata keep the plain select.

Co-authored-by: dengliu <[email protected]>
2026-11-16 02:07:32 +08:00
l0ng-ai 8535fb7645 fix(agent): show an agent's form fields in its schema's order (#3584)
serde_json's Value keeps object keys sorted, so elicitation/create forms
listed their fields alphabetically. JSON-RPC handlers can now read the
request as received (Handler::on_request_raw), and the form takes its field
order from requestedSchema.properties there. The ACP dialect wrapper
forwards the raw request.
2026-11-16 01:47:38 +08:00
l0ng-ai 89eacbe640 fix(agent): offer the effort levels a session reports for ACP models discovery didn't read (#3582)
ACP discovery opens one session and knows only that session's model's
effort levels. Once discovery succeeded, session reports no longer reached
the New Task catalog, so other models of the agent showed no effort choice.
A session on another listed model now records that model's levels in the
project's catalog (never the discovered model's), and a later discovery
keeps them while the model is still listed.
2026-11-16 01:23:42 +08:00
Mr0Bread c67b336a8a feat(agent): answer ACP form elicitation (#3580)
The ACP client advertises elicitation.form and answers elicitation/create: the request's restricted JSON Schema becomes form fields (text, number, integer, boolean, single and multiple choice, with titles, descriptions, defaults, required and bounds), shown in the question card above the composer. Submit replies accept with typed values, Decline replies decline, Cancel or Escape replies cancel; the conversation records the answers. Nested objects and URL mode are refused with INVALID_PARAMS. Bypassing approvals never answers a form.

Part of #3536

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 01:11:16 +08:00
Akbar Husain 90cc707d00 feat(zig): Settings page and detection for the Zig toolchain (#3578)
Adds Settings › Languages & Frameworks › Zig for languages.zig.zig (used by the Zig External Libraries), on the shared SDK row: Auto = zig on PATH, Detect again, Browse. ide-sdk gains SdkKind::Zig, detecting PATH installs (shims skipped), Homebrew, zvm and mise / asdf installs; the stored path is the directory holding zig, with the version from zig version.

Co-authored-by: akbar1214 <[email protected]>
2026-11-16 01:05:20 +08:00
Mr0Bread 779080781b fix(agent): keep each ACP model's own effort levels (#3577)
ACP discovery opens one session and reads its thought_level option, which describes that session's model only, but recorded it under every model value. Agents whose models offer different effort lists (e.g. OMP's Copilot gpt-4o: off/auto) showed levels the agent rejects. Discovery now records the levels for the session's model only.

Part of #3536

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 01:00:29 +08:00
Bbu Gulli Annтаctro15547 ccf87e2476 fix(macos): support macOS 12 again (#3576)
Lowers the minimum macOS version to 12.0 and the WebKit floor to Safari 15.4: Vite targets safari15.4, check-webkit enforces the 15.4 floor and rejects Array toSorted/toReversed/toSpliced, showPicker and Intl.supportedValuesOf in startup chunks, the boot outdated check tests structuredClone, the Database view drops toReversed, and the install docs list macOS 12. Container-query layouts degrade on macOS 12.

Fixes #3413

Co-authored-by: ctro15547 <[email protected]>
2026-11-16 00:58:42 +08:00
l0ng-ai 76f0b3fc63 feat(project-view): Open in Right Split (⇧Enter) (#3579)
The Project view opened a file in the current pane whatever the modifier and had no way to open one in a split. Open in Right Split opens the selected files in the pane to the right: ⇧Enter in the IntelliJ keymaps, ⌘Enter / Ctrl+Enter in the VS Code ones, and in the context menu before Open In (External Libraries too). On a folder ⇧Enter keeps expanding it.

Closes #3549
2026-11-16 00:55:52 +08:00
l0ng-ai a5c14a4c21 fix(notebook): Vim u and Ctrl-R undo and redo in notebook cells (#3575)
Notebook cells keep no CodeMirror history (edits share the notebook's backend history), so Vim's u / Ctrl-R did nothing there. An editor can provide externalHistory; notebook cells provide the session's, and the Vim engine sends undo / redo to it.
2026-11-16 00:11:43 +08:00
Akbar Husain cf444a31f7 perf(index): allocation-free hump scoring and byte-scan name boosts (#3573)
hump_bonus walks the candidate's chars once with a cursor into the sorted, deduplicated match indices instead of collecting a Vec<char> per ranked survivor, and name_boost finds the extension dot with a byte scan. Results are unchanged; find_symbols/OCo goes from 3.71 ms to 3.36 ms in the index bench.

Co-authored-by: akbar1214 <[email protected]>
2026-11-16 00:05:57 +08:00
Mr0Bread c3763fb806 feat(agent): steer agents that advertise _session/steering (#3568)
Messages typed while a task is running join the running turn for agents that advertise the _session/steering extension (_meta.steering.supported in the initialize response, as OMP does), not only those advertising _meta.openidea.steer. An outcome other than injected (promptRequired, failed) queues the message for the next turn.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-16 00:01:30 +08:00
Mr0Bread 9a518e734b refactor(styles): put gap on the spacing scale (#3562)
Extends the 4px-grid spacing scale with --space-0-5 (2px), --space-1 (4px) and --space-1-5 (6px) and replaces the literal 2/4/6/8/12/16px gap, row-gap and column-gap values with the tokens; computed values are unchanged. src/styles/spacingScale.test.ts flags an on-scale literal gap.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 23:59:47 +08:00
Mr0Bread 2913c12a19 chore(styles): remove CSS module classes no component reads (#3560)
35 CSS-module classes in 10 modules were defined but never read (no styles.x use, no composes, no dynamic access in their importers): leftovers of removed UI such as the settings code-preview block, the GitHub tool window's layout helpers and SDK tree rows.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 23:58:00 +08:00
Mr0Bread 3044518b88 fix(styles): restore CSS classes components still reference (#3559)
Components read CSS-module classes no module defined, which are undefined at runtime. The archived-task banner in agent sessions (.banner, .bannerText, .bannerSpacer, dropped in #2083) and the Create Patch directory row (.cloneDirRow, .cloneDirInput, dropped in #866) are restored; references to classes that never existed are removed. src/styles/cssModules.test.ts lists any styles.x the imported module lacks.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 23:56:20 +08:00
Vadim LazukтаInf1mum 778472854e fix(markdown): keep the Preview only scroll position when switching tabs back (#3569)
Tab switches unmount the preview, and its saved offset was applied once at remount, before images and diagrams had drawn. A restore that clamped to the short document is applied again on each settle until it fits or the reader scrolls; the clamped write no longer replaces the saved offset. The preview opts out of scroll anchoring, which in WebView2 kept a later block in view while images above it loaded.

Fixes #3537

Co-authored-by: Inf1mum <[email protected]>
2026-11-15 23:54:36 +08:00
l0ng-ai 1679d61a6a fix(services): list the SSH configurations in the Docker connection dialog (#3570)
SshConfigSelect reads the settings window's staged ssh.configs unless given a list, and that list is empty outside the settings window, so the Docker connection dialog offered only None. The dialog now passes the live list; the None choice gets its Chinese text.

Closes #3535
2026-11-15 23:51:21 +08:00
l0ng-ai 937008a6fe fix(lsp): highlight a library class the Kotlin server shows as Java as Java (#3565)
A virtual .class document has no language by its name, so it took its server's first language: the Kotlin language server's Java stubs of jar classes were highlighted as Kotlin and their Java keywords stayed uncoloured. A .class stub whose package or import line ends with ';' is now Java (virtual_doc::is_java_class_stub).
2026-11-15 23:40:00 +08:00
l0ng-ai dc3d2050cb fix(editor): colour this and super as keywords in Java, JavaScript, TypeScript and C++ (#3564)
The Lezer grammars tag `this` as `self`, which the colour scheme paints with Python's self-parameter colour, and @lezer/javascript tags `super` as an atom. JAVA_TAGS (also used by JSP), JS_TAGS and a new C++ entry restyle them as keywords. Python's `self` is unchanged.
2026-11-15 23:37:06 +08:00
Mr0Bread 732baf8f1e fix(styles): use defined tokens where CSS read --bg-subtle, --bg-toolbar and --fg-primary (#3550)
Three custom properties were read without a fallback and declared nowhere, so they resolved to their initial values: no background on the Working Status and Command Permissions rows, none on the console Find button (its icon sat over console text), and an inherited hover colour. They now use --bg-hover, --bg-elev and --fg-strong; a dead var(--bg) fallback in Run.module.css is dropped. A test lists any var(--x) without a fallback whose --x is declared nowhere.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 23:31:12 +08:00
Mansur Akhmadiтаmansur-akhmadi 9b6e5a5a62 feat(vcs): copy a file's link on its Git remote from Copy Path/Reference… (#3553)
Copy Path/Reference… gains a <hosting> Link item (GitHub Link, GitLab Link, Bitbucket Link) that copies the page Open In › <hosting> opens; in the editor the link covers the selected lines (#L2-L3 / #L2-3 / #lines-2:3). A copyPath menu id lets features add items to the chooser, so editor/core and project-tree stay free of Git code.

Closes #3552

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 23:29:54 +08:00
l0ng-ai 8c2c701517 feat(keymap): list Cut, Copy, Paste, Undo, Redo and Select All in Settings › Keymap (#3563)
The Edit actions are hidden from Search Everywhere, and the Keymap page skipped hidden actions with no preset shortcut, so they could not get a second key (Ctrl+Shift+C / V / X for Vim users). A keymap flag on an action keeps it listed; the settings window's action list (describeActions) carries it too.
2026-11-15 23:25:59 +08:00
l0ng-ai aeb689c632 test(app): the command-line diff opens with Swap Sides (#3557)
#3540 made the command-line diff swappable; the launch tests in windows.test.ts still expected the diff data without the flag.
2026-11-15 23:17:53 +08:00
l0ng-ai 80c25be389 fix(keymap): list the data editor actions in the VS Code preset (#3556)
The VS Code preset must name every IntelliJ action id; #3551 added the db.* data editor actions to the IntelliJ presets only, failing keymap.test.ts on main.
2026-11-15 23:16:27 +08:00
Sameh Elhawaryтаsamehelhawary acea7e6235 feat(php): Phoxite as a selectable PHP language server (#3541)
languages/php.toml gains a phoxite entry (phoxite-lsp over stdio), a fallback listed after phpactor; Intelephense stays the default and users pick Phoxite under Language Servers. ide-tools installs it from the samehelhawary/phoxite GitHub releases (a binary per platform with a .sha256 beside it). A server entry can now name the servers it replaces: those stay away while it serves the file (Phoxite replaces laravel-lsp, which it covers), including when the replacing fallback takes over after a server gives up.

Co-authored-by: samehelhawary <[email protected]>
2026-11-15 23:14:32 +08:00
l0ng-ai 25197b3215 fix(vcs): show a committed directory symlink's target in revision diffs (#3554)
Revision content was decoded with the working file's encoding, read whenever the path existed. For a symlink to a folder that path is a directory, so loading failed and the Git Log diff showed '<path> is a directory'. The working file is now consulted only when it is a regular file; otherwise the encoding is detected from the revision's blob.

Closes #3528
2026-11-15 23:11:50 +08:00
Vadim LazukтаInf1mum 55786a7030 fix(vcs): the Git Log rename label names the original directory (#3546)
The changed-file label trimmed a rename's old path to its base name, so a move between directories looked like a rename in place. A shared renameFrom() now says 'renamed from <name>' within one directory and 'moved from <old path relative to the new directory>' otherwise, as IDEA does; Git Log and the GitHub create / pull request change trees use it.

Closes #3529

Co-authored-by: Inf1mum <[email protected]>
2026-11-15 23:09:13 +08:00
Vadim LazukтаInf1mum b7adbbb17c fix(project): mark symlinks with a link arrow in the tree (#3547)
Directory and file symlinks used the same icons as real folders and files. The Project view now overlays IDEA's link arrow (expui nodes/symlink) on every entry the listing reports as a symlink; the model already carried the flag, so no extra stat calls.

Closes #3527

Co-authored-by: Inf1mum <[email protected]>
2026-11-15 23:06:39 +08:00
l0ng-ai d1290c76ea feat(database): data editor row and toolbar commands as actions (#3551)
Reload Page, First / Previous / Next / Last Page, Add Row, Export Data, Delete Rows, Restore Rows, Edit Cell and the cell copy commands were menu items or toolbar buttons only. They are now db.* actions supplied by the table editor and the console result grid through their focus handle, so they appear in Find Action and take Keymap shortcuts; menus and buttons run the same actions. DataGrip's defaults bind Reload Page, Previous / Next Page and Add Row, and Delete Rows on Windows and Linux.

Closes #3506
2026-11-15 23:03:35 +08:00
l0ng-ai 0febd3e170 fix(editor): Sort Lines orders by character code (#3548)
Sort Lines used a locale-aware collator, which interleaves upper- and lowercase letters. It now compares UTF-16 code units, the order IDEA's Sort Lines uses (String.compareTo) and LC_ALL=C sort gives.

Closes #3516
2026-11-15 22:47:11 +08:00
455 змінених файлів з 6571 додано та 1759 видалено

No files matched your search

+1
Переглянути файл
@@ -42,6 +42,7 @@ Visual style: dark, low-contrast greys, one cool accent
shortcuts live in `src/platform/keymaps/`.
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
`gap` uses the `--space-*` scale (`src/styles/tokens.css`, 4px grid) where a value is on it.
- User-visible text goes through the text catalog: add every new string to both
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
Other locales may lack entries; their UI shows the English text until a translator adds it.
+8 -8
Переглянути файл
@@ -38,14 +38,14 @@ Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| ---------------------------------- | --------------------- | ---------------------- |
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
| Kapanma | **0,3 sn** | 5,9 sn |
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| ---------------------------- | ----------------------- | ---------------------- |
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
| Kapanma | **0,3 sn** | 5,9 sn |
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
+14 -17
Переглянути файл
@@ -10,12 +10,14 @@ use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
/// Opens `session/new`, reads `configOptions`, and records the `thought_level` choices of the
/// session's model: they describe that model only, and other models may offer different ones.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
match run_once(shared, spec, prefer_stored).await {
@@ -62,21 +64,17 @@ async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
let model = options.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone());
let levels = match (model, levels_for_model_from_slice(&options)) {
(Some(model), Some(effort)) => BTreeMap::from([(model, effort)]),
_ => BTreeMap::new(),
};
let commands = updates
.commands
.lock()
.clone()
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
Ok::<_, String>(Discovered { config: options, commands, levels })
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
@@ -108,18 +106,17 @@ impl ide_jsonrpc::Handler for DiscoveryUpdates {
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| is_thought_level(o))
.filter(|o| config::is_per_model_option(o))
.cloned()
.map(|mut o| {
if o.category.as_deref() != Some("thought_level") {
if config::is_context_option(&o.id) {
if o.category.is_none() {
o.category = Some("model_config".into());
}
} else if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
+9
Переглянути файл
@@ -58,6 +58,8 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Forms the agent asks the user to fill in (`elicitation/create`); not URL mode.
"elicitation": { "form": {} },
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
@@ -320,6 +322,13 @@ impl Handler for Dialect {
}
self.inner.on_request(method, params)
}
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
if self.v2() && method == "session/request_permission" {
return self.on_request_with_data(method, params);
}
self.inner.on_request_raw(method, params, raw)
}
}
/// A v2 permission request with the v1 `toolCall` (its subject's, titled with the request's title).
+5 -1
Переглянути файл
@@ -97,10 +97,13 @@ fn the_negotiating_request_carries_both_versions() {
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// Forms only: URL mode would open pages the agent names.
assert_eq!(p["clientCapabilities"]["elicitation"], json!({ "form": {} }));
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
expected["elicitation"] = p["clientCapabilities"]["elicitation"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
@@ -168,7 +171,8 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
// The effort option belongs to the session's model; m2 may offer other levels.
assert_eq!(cursor.thought_levels.keys().collect::<Vec<_>>(), ["m1"]);
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
}
+53 -1
Переглянути файл
@@ -56,6 +56,16 @@ pub(crate) struct CatalogData {
pub refreshed_at_ms: Option<f64>,
}
impl CatalogData {
fn model_option(&self) -> Option<&AgentConfigOption> {
self.config.iter().find(|o| o.category.as_deref() == Some("model"))
}
fn lists(&self, model: &str) -> bool {
self.model_option().is_some_and(|o| o.values.iter().any(|v| v.value == model))
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedCatalog {
@@ -329,11 +339,17 @@ impl AgentCatalogs {
Ok(found) => {
let mut cache = shared.known.lock();
let known = cache.entry(key.2.clone()).or_default();
let catalog = CatalogData {
let mut catalog = CatalogData {
config: found.config,
thought_levels: found.levels,
refreshed_at_ms: Some(self.epoch_ms()),
};
// Efforts sessions reported for models discovery doesn't read stay while listed.
for (model, levels) in entry.catalog.take().map(|old| old.thought_levels).unwrap_or_default() {
if catalog.lists(&model) && !catalog.thought_levels.contains_key(&model) {
catalog.thought_levels.insert(model, levels);
}
}
known.config = catalog.config.clone();
known.thought_levels = catalog.thought_levels.clone();
if let Some(commands) = found.commands {
@@ -366,6 +382,42 @@ impl AgentCatalogs {
}
}
/// Records the efforts a session reported for a listed `model` other than the one discovery
/// read: ACP discovery opens one session, so it knows only that model's efforts.
pub(crate) fn learn_levels(&self, shared: &Shared, provider: &str, model: &str, levels: Vec<AgentConfigOption>) {
let lacks = |catalog: &CatalogData| {
catalog.lists(model)
&& catalog.model_option().and_then(|o| o.current.as_deref()) != Some(model)
&& catalog.thought_levels.get(model) != Some(&levels)
};
let key = Self::key(shared, provider);
let mut state = self.state.lock();
let mut learned = false;
if let Some(catalog) = state.entries.get_mut(&key).and_then(|e| e.catalog.as_mut()).filter(|c| lacks(c)) {
catalog.thought_levels.insert(model.to_owned(), levels.clone());
learned = true;
}
let mut cache = shared.known.lock();
if let Some(saved) = cache
.get_mut(provider)
.and_then(|known| known.catalogs.get_mut(&shared.root_key()))
.filter(|saved| lacks(&saved.catalog))
{
saved.catalog.thought_levels.insert(model.to_owned(), levels);
learned = true;
}
if !learned {
return;
}
shared.persist_known(&cache);
drop(cache);
let snapshot = state.entries.get(&key).map(|entry| self.snapshot(shared, provider, entry));
drop(state);
if let Some(snapshot) = snapshot {
self.broadcast(shared, &snapshot);
}
}
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
self.state
.lock()
+38 -1
Переглянути файл
@@ -199,7 +199,8 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(model_values(&fresh), ["retained", "new"]);
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
// The session's efforts describe its own model, not "new".
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["retained"]);
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
@@ -220,6 +221,42 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
}
#[tokio::test]
async fn a_session_adds_the_efforts_of_a_model_discovery_did_not_read() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let mut harness = Harness::new();
let catalogs = Arc::new(AgentCatalogs::default());
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["first", "second"], &["low", "high"]))).unwrap();
completed(&mut events).await;
// A session on "second" reports its own efforts; one on a model the catalog lacks, or on
// "first" with other efforts, changes nothing.
let second = config::options_of(&response(&["second"], &["off", "auto"]));
manager.shared.remember(PROVIDER, Some(&second), None, Some("second"));
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["gone"], &["x"]))), None, Some("gone"));
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["first"], &["max"]))), None, Some("first"));
let efforts = |catalog: &AgentModelCatalog, model: &str| -> Vec<String> {
catalog.thought_levels[model][0].values.iter().map(|v| v.value.clone()).collect()
};
let catalog = manager.refresh_models(PROVIDER, false).unwrap();
assert_eq!(catalog.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(efforts(&catalog, "first"), ["low", "high"]);
assert_eq!(efforts(&catalog, "second"), ["off", "auto"]);
let restored = make_manager(tmp.path(), Arc::default(), &harness).refresh_models(PROVIDER, false).unwrap();
assert_eq!(efforts(&restored, "second"), ["off", "auto"], "persisted with the project's catalog");
// Discovery again reads "first" only; "second" keeps what its session reported.
advance(&catalogs, 60);
while events.try_recv().is_ok() {}
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["first", "second"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(efforts(&fresh, "first"), ["medium"]);
assert_eq!(efforts(&fresh, "second"), ["off", "auto"]);
}
#[tokio::test]
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
let tmp = tempfile::tempdir().unwrap();
+100 -11
Переглянути файл
@@ -20,17 +20,22 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
description: Some("Tool approvals for this Cursor conversation".into()),
category: Some("approval".into()),
current: Some("ask".into()),
default_value: Some("ask".into()),
via: AgentConfigVia::Option,
values: vec![
AgentConfigValue {
value: "ask".into(),
name: "Ask for approval".into(),
description: Some("Show permission requests from Cursor".into()),
cost_category: None,
usage_multiplier: None,
},
AgentConfigValue {
value: "bypass".into(),
name: "Bypass approvals".into(),
description: Some("Automatically approve tool requests in this conversation".into()),
cost_category: None,
usage_multiplier: None,
},
],
});
@@ -118,6 +123,18 @@ pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
out
}
pub(crate) fn is_context_option(id: &str) -> bool {
matches!(id, "context_tier" | "contextTier")
}
/// Whether an option's available values and default belong to the selected model
/// (`thought_level` and model context-tier options) rather than the provider as a whole.
pub(crate) fn is_per_model_option(o: &AgentConfigOption) -> bool {
matches!(o.category.as_deref(), Some("thought_level" | "context_tier"))
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|| is_context_option(&o.id)
}
/// 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() };
@@ -133,6 +150,8 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
None => values.extend(value_of(v)),
}
}
let current = str_of(o, "currentValue");
let default_value = str_of(o, "defaultValue").or_else(|| current.clone());
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
@@ -143,9 +162,10 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
.or_else(|| (id == "fast" || is_context_option(&id)).then(|| "model_config".into()))
}),
current: str_of(o, "currentValue"),
current,
default_value,
values,
via: AgentConfigVia::Option,
id,
@@ -155,9 +175,24 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
}
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
/// stand-ins stay.
/// stand-ins stay, and initial `default_value`s survive same-model option edits for Reset.
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let mut fresh = parse_options(options);
let old_model = config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
let new_model = fresh.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
let same_model = old_model == new_model;
for f in &mut fresh {
if f.default_value == f.current
&& (same_model || !is_per_model_option(f))
&& let Some(prev_default) = config
.iter()
.find(|p| p.id == f.id)
.and_then(|p| p.default_value.as_ref())
.filter(|def| f.values.iter().any(|v| v.value == **def))
{
f.default_value = Some(prev_default.clone());
}
}
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
let legacy = std::mem::take(config);
@@ -251,16 +286,20 @@ fn legacy(
Some(AgentConfigValue {
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
description: str_of(m, "description"),
cost_category: None,
usage_multiplier: None,
value,
})
})
.collect();
let current = str_of(v, current);
AgentConfigOption {
id: id.to_owned(),
name: name.to_owned(),
description: None,
category: Some(id.to_owned()),
current: str_of(v, current),
default_value: current.clone(),
current,
values,
via,
}
@@ -268,9 +307,23 @@ fn legacy(
fn value_of(v: &Value) -> Option<AgentConfigValue> {
let value = v.get("value")?.as_str()?.to_owned();
let meta = v.get("_meta");
let cost_category = meta
.and_then(|m| m.get("copilotPriceCategory").or_else(|| m.get("priceCategory")))
.and_then(Value::as_str)
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let usage_multiplier = meta
.and_then(|m| m.get("copilotUsage").or_else(|| m.get("usageMultiplier")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
Some(AgentConfigValue {
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
description: str_of(v, "description"),
cost_category,
usage_multiplier,
value,
})
}
@@ -402,17 +455,53 @@ mod tests {
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
let mut c = options_of(&json!({
"configOptions": [
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
"options": [{"value": "gemini-4-carbon", "name": "Gemini 4 Carbon",
"description": "Powerful · Medium cost",
"_meta": {"copilotPriceCategory": "Medium", "copilotUsage": "1x"}}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "contextTier", "type": "select", "currentValue": "default",
"options": [{"value": "default", "name": "200K"}, {"value": "long_context", "name": "1M"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
assert_eq!(c.len(), 4);
assert_eq!(c[0].values[0].cost_category.as_deref(), Some("medium"));
assert_eq!(c[0].values[0].usage_multiplier.as_deref(), Some("1x"));
assert_eq!(c[1].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].default_value.as_deref(), Some("medium"));
assert_eq!(c[2].category.as_deref(), Some("model_config"));
assert!(is_per_model_option(&c[1]) && is_per_model_option(&c[2]) && !is_per_model_option(&c[3]));
// Editing effort on the same model preserves its initial `default_value` for Reset.
merge_update(
&mut c,
&json!([
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
"options": [{"value": "gemini-4-carbon"}]},
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
]),
);
let effort = c.iter().find(|o| o.id == "effort").unwrap();
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("high"), Some("medium")));
// Switching models refreshes per-model `default_value` to the new model's default.
merge_update(
&mut c,
&json!([
{"id": "model", "type": "select", "currentValue": "gpt-6-sol",
"options": [{"value": "gemini-4-carbon"}, {"value": "gpt-6-sol"}]},
{"id": "effort", "type": "select", "currentValue": "low",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
]),
);
let effort = c.iter().find(|o| o.id == "effort").unwrap();
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("low"), Some("low")));
}
#[test]
+305
Переглянути файл
@@ -0,0 +1,305 @@
//! ACP elicitation in form mode: the agent's restricted JSON Schema as form fields, and the
//! user's answer as the `elicitation/create` response.
use crate::model::{AgentForm, AgentFormField, AgentFormFieldKind, AgentFormOption, AgentPermissionOption};
use serde::Deserialize;
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
use serde_json::{Map, Value, json};
/// The reply that declines the form; a JSON object of field name to values accepts it, and
/// anything else cancels it.
const DECLINE: &str = "decline";
/// The form `elicitation/create` asks for; `Err` when it asks for something else.
pub(crate) fn form_of(params: &Value) -> Result<AgentForm, String> {
if params.get("mode").and_then(Value::as_str) != Some("form") {
return Err("only form elicitation is supported".to_owned());
}
let schema = params.get("requestedSchema").ok_or("requestedSchema is missing")?;
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|names| names.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let properties = schema.get("properties").and_then(Value::as_object).ok_or("requestedSchema has no properties")?;
let fields = properties
.iter()
.map(|(name, property)| field(name, property, required.contains(&name.as_str())))
.collect::<Result<_, _>>()?;
Ok(AgentForm { fields })
}
/// Orders the fields as the request's schema lists them: `Value` keeps object keys sorted, and
/// the agent's order is the one its form reads in.
pub(crate) fn in_schema_order(form: &mut AgentForm, raw: &[u8]) {
#[derive(Deserialize)]
struct Request {
params: Params,
}
#[derive(Deserialize)]
struct Params {
#[serde(rename = "requestedSchema")]
schema: Schema,
}
#[derive(Deserialize)]
struct Schema {
properties: Keys,
}
struct Keys(Vec<String>);
impl<'de> Deserialize<'de> for Keys {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visit;
impl<'de> Visitor<'de> for Visit {
type Value = Keys;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Keys, A::Error> {
let mut keys = Vec::new();
while let Some((key, IgnoredAny)) = map.next_entry::<String, IgnoredAny>()? {
keys.push(key);
}
Ok(Keys(keys))
}
}
deserializer.deserialize_map(Visit)
}
}
let Ok(request) = serde_json::from_slice::<Request>(raw) else { return };
let order = request.params.schema.properties.0;
form.fields.sort_by_key(|field| order.iter().position(|name| *name == field.name).unwrap_or(usize::MAX));
}
fn field(name: &str, property: &Value, required: bool) -> Result<AgentFormField, String> {
let text = |key: &str| property.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_owned();
let number = |key: &str| property.get(key).and_then(Value::as_f64);
let unsupported = || format!("field {name} has an unsupported schema");
let (kind, options, min, max) = match property.get("type").and_then(Value::as_str) {
Some("string") => match choices(property) {
Some(options) => (AgentFormFieldKind::Choice, options, None, None),
None => (AgentFormFieldKind::Text, Vec::new(), number("minLength"), number("maxLength")),
},
Some("number") => (AgentFormFieldKind::Number, Vec::new(), number("minimum"), number("maximum")),
Some("integer") => (AgentFormFieldKind::Integer, Vec::new(), number("minimum"), number("maximum")),
Some("boolean") => (AgentFormFieldKind::Boolean, Vec::new(), None, None),
Some("array") => {
let options = property.get("items").and_then(choices).ok_or_else(unsupported)?;
(AgentFormFieldKind::Choices, options, number("minItems"), number("maxItems"))
}
_ => return Err(unsupported()),
};
let default = match property.get("default") {
Some(Value::Array(values)) => values.iter().filter_map(scalar).collect(),
Some(value) => scalar(value).into_iter().collect(),
None => Vec::new(),
};
let title = text("title");
Ok(AgentFormField {
name: name.to_owned(),
title: if title.is_empty() { name.to_owned() } else { title },
description: text("description"),
required,
kind,
options,
default,
min,
max,
})
}
/// The values of a choice: `enum` (titled by the legacy `enumNames`), or `oneOf` / `anyOf`
/// of `{ const, title }`.
fn choices(schema: &Value) -> Option<Vec<AgentFormOption>> {
let option = |value: &str, label: Option<&str>, description: Option<&str>| AgentFormOption {
value: value.to_owned(),
label: label.filter(|l| !l.trim().is_empty()).unwrap_or(value).to_owned(),
description: description.unwrap_or_default().trim().to_owned(),
};
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
let names = schema.get("enumNames").and_then(Value::as_array);
return Some(
values
.iter()
.enumerate()
.filter_map(|(i, value)| {
Some(option(value.as_str()?, names.and_then(|n| n.get(i)).and_then(Value::as_str), None))
})
.collect(),
);
}
let listed = schema.get("oneOf").or_else(|| schema.get("anyOf"))?.as_array()?;
Some(
listed
.iter()
.filter_map(|o| {
let text = |key: &str| o.get(key).and_then(Value::as_str);
Some(option(text("const")?, text("title"), text("description")))
})
.collect(),
)
}
fn scalar(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
/// A form is submitted from its own card; this option only names a declined form.
pub(crate) fn options() -> Vec<AgentPermissionOption> {
vec![AgentPermissionOption { id: DECLINE.to_owned(), name: "Decline".to_owned(), kind: "reject_once".to_owned() }]
}
/// The `elicitation/create` response to the user's `reply`, and what the conversation
/// records as chosen.
pub(crate) fn answer(form: &AgentForm, reply: Option<&str>) -> (Value, String) {
let values = match reply {
Some(DECLINE) => return (json!({ "action": "decline" }), "Decline".to_owned()),
Some(reply) => serde_json::from_str::<Map<String, Value>>(reply).ok(),
None => None,
};
let Some(values) = values else { return (json!({ "action": "cancel" }), "Cancelled".to_owned()) };
let mut content = Map::new();
let mut shown = Vec::new();
for field in &form.fields {
let given: Vec<&str> = match values.get(&field.name) {
Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).collect(),
Some(Value::String(item)) => vec![item.as_str()],
_ => Vec::new(),
};
let given: Vec<&str> = given.into_iter().map(str::trim).filter(|v| !v.is_empty()).collect();
let Some(&first) = given.first() else { continue };
let value = match field.kind {
AgentFormFieldKind::Text | AgentFormFieldKind::Choice => json!(first),
AgentFormFieldKind::Number => match first.parse::<f64>() {
Ok(number) => json!(number),
Err(_) => continue,
},
AgentFormFieldKind::Integer => match first.parse::<i64>() {
Ok(number) => json!(number),
Err(_) => continue,
},
AgentFormFieldKind::Boolean => json!(first == "true"),
AgentFormFieldKind::Choices => json!(given),
};
let label = |value: &str| {
field.options.iter().find(|o| o.value == value).map_or_else(|| value.to_owned(), |o| o.label.clone())
};
let text = match field.kind {
AgentFormFieldKind::Boolean => (if first == "true" { "yes" } else { "no" }).to_owned(),
AgentFormFieldKind::Choices => given.iter().map(|v| label(v)).collect::<Vec<_>>().join(", "),
_ => label(first),
};
shown.push(format!("{}: {text}", field.title));
content.insert(field.name.clone(), value);
}
let chosen = if shown.is_empty() { "Submitted".to_owned() } else { shown.join("; ") };
(json!({ "action": "accept", "content": content }), chosen)
}
#[cfg(test)]
mod tests {
use super::*;
/// The form OMP's `ask` tool sends: a choice with a recommended default, then an
/// optional typed answer beside it.
fn ask() -> Value {
json!({
"sessionId": "s", "mode": "form", "message": "Which database?",
"requestedSchema": { "type": "object", "properties": {
"q0": { "type": "string", "title": "Which database?", "description": "Storage",
"oneOf": [{ "const": "pg", "title": "Postgres", "description": "Relational" },
{ "const": "sqlite", "title": "SQLite" }],
"default": "pg" },
"q0__other": { "type": "string", "title": "Other (type your own)" }
} }
})
}
#[test]
fn fields_follow_the_schema_order() {
let raw = br#"{ "jsonrpc": "2.0", "id": 3, "method": "elicitation/create", "params": { "mode": "form",
"requestedSchema": { "type": "object", "properties": {
"strategy": { "type": "string" }, "tests": { "type": "boolean" }, "note": { "type": "string" }
} } } }"#;
let params = serde_json::from_slice::<Value>(raw).unwrap()["params"].take();
let mut form = form_of(&params).unwrap();
in_schema_order(&mut form, raw);
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests", "note"]);
}
#[test]
fn reads_choices_and_text_fields() {
let form = form_of(&ask()).unwrap();
let [choice, other] = form.fields.as_slice() else { panic!("{form:?}") };
assert_eq!((choice.name.as_str(), choice.kind, choice.required), ("q0", AgentFormFieldKind::Choice, false));
assert_eq!(choice.options[0].label, "Postgres");
assert_eq!(choice.options[0].description, "Relational");
assert_eq!(choice.default, ["pg"]);
assert_eq!((other.kind, other.title.as_str()), (AgentFormFieldKind::Text, "Other (type your own)"));
}
#[test]
fn reads_every_restricted_schema_kind() {
let form = form_of(&json!({ "mode": "form", "requestedSchema": {
"type": "object", "required": ["count"],
"properties": {
"count": { "type": "integer", "minimum": 1, "maximum": 5 },
"ratio": { "type": "number" },
"ok": { "type": "boolean", "default": true },
"size": { "type": "string", "enum": ["s", "l"], "enumNames": ["Small", "Large"] },
"tags": { "type": "array", "items": { "anyOf": [{ "const": "a" }, { "const": "b" }] },
"default": ["b"], "maxItems": 2 }
}
} }))
.unwrap();
let field = |name: &str| form.fields.iter().find(|f| f.name == name).unwrap();
use AgentFormFieldKind::*;
let kinds: Vec<_> = ["count", "ratio", "ok", "size", "tags"].map(|name| field(name).kind).into();
assert_eq!(kinds, [Integer, Number, Boolean, Choice, Choices]);
assert!(field("count").required && !field("ratio").required);
assert_eq!((field("count").min, field("count").max), (Some(1.0), Some(5.0)));
assert_eq!(field("ok").default, ["true"]);
assert_eq!(field("size").options[1].label, "Large");
assert_eq!((field("tags").default.as_slice(), field("tags").max), (["b".to_owned()].as_slice(), Some(2.0)));
}
#[test]
fn url_mode_and_unsupported_fields_are_refused() {
assert!(form_of(&json!({ "mode": "url", "url": "https://example.com" })).is_err());
let nested = json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
"address": { "type": "object" }
} } });
assert!(form_of(&nested).is_err());
}
#[test]
fn an_answer_is_typed_by_its_field() {
let form = form_of(&json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
"count": { "type": "integer" },
"ok": { "type": "boolean" },
"size": { "type": "string", "oneOf": [{ "const": "s", "title": "Small" }] },
"tags": { "type": "array", "items": { "enum": ["a", "b"] } },
"note": { "type": "string" }
} } }))
.unwrap();
let reply = json!({ "count": ["3"], "ok": ["false"], "size": ["s"], "tags": ["a", "b"], "note": [" "] });
let (response, chosen) = answer(&form, Some(&reply.to_string()));
assert_eq!(
response,
json!({ "action": "accept", "content": { "count": 3, "ok": false, "size": "s", "tags": ["a", "b"] } })
);
assert_eq!(chosen, "count: 3; ok: no; size: Small; tags: a, b");
}
#[test]
fn declining_and_dismissing_send_no_content() {
let form = form_of(&ask()).unwrap();
assert_eq!(answer(&form, Some(DECLINE)), (json!({ "action": "decline" }), "Decline".to_owned()));
assert_eq!(answer(&form, None).0, json!({ "action": "cancel" }));
assert_eq!(answer(&form, Some("cancel")).0, json!({ "action": "cancel" }));
}
}
+124 -34
Переглянути файл
@@ -11,6 +11,7 @@ mod acp;
mod catalogs;
mod config;
mod conversation;
mod elicitation;
mod external;
pub mod mcp;
mod model;
@@ -187,7 +188,7 @@ impl Shared {
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config.retain(|o| !config::is_per_model_option(o));
config::add_client_options(provider, &mut config);
let hook = self.new_task_approvals.read().clone();
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
@@ -223,6 +224,7 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
let acp = self.spec(provider).is_some_and(|spec| spec.driver == provider::Driver::Acp);
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
@@ -232,8 +234,7 @@ impl Shared {
&& let Some(config) = config.filter(|c| !c.is_empty())
{
if let Some(model) = model {
let levels: Vec<_> =
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
@@ -244,6 +245,11 @@ impl Shared {
changed = true;
}
}
// Discovery reads one model's options; a session on another model reports that model's.
let learned = model.filter(|_| acp && known.authoritative).zip(config).map(|(model, config)| {
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
(model, levels)
});
if let Some(commands) = commands.filter(|c| !c.is_empty())
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
{
@@ -254,6 +260,9 @@ impl Shared {
self.persist_known(&cache);
}
drop(cache);
if let Some((model, levels)) = learned.filter(|(_, levels)| !levels.is_empty()) {
self.paths.catalogs.learn_levels(self, provider, model, levels);
}
if changed {
self.emit(AgentEvent::Providers);
}
@@ -1000,8 +1009,11 @@ impl AgentManager {
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
// The task's copy holds its model's per-model options unless a model switch the agent
// never reported dropped them: caching that copy would erase the model's known levels.
let mut model_options = (target.category.as_deref() != Some("model")
|| previous.as_deref() == Some(value.as_str()))
&& task.state.lock().task.config.iter().any(config::is_per_model_option);
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
@@ -1032,7 +1044,7 @@ impl AgentManager {
{
let mut s = task.state.lock();
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
s.task.config.retain(|o| !config::is_per_model_option(o));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value);
@@ -1861,12 +1873,24 @@ impl Task {
let s = self.state.lock();
s.busy && s.task.status == AgentStatus::Running
};
let Some(live) = live.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images)) else {
let Some((live, method)) = live
.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images))
.and_then(|l| steer_method(&l.init).map(|m| (l, m)))
else {
return Err(prompt);
};
let content = self.content(&live, &prompt).await;
let sent: Result<Value, RpcError> =
live.peer.request("_openidea/steer", json!({ "sessionId": live.session, "prompt": content }), None).await;
live.peer.request(method, json!({ "sessionId": live.session, "prompt": content }), None).await;
// `_session/steering` answers with an outcome: only `injected` joined the turn
// (`promptRequired`: no turn is running any more).
let sent = match sent {
Ok(reply) => match reply.get("outcome").and_then(Value::as_str) {
None | Some("injected") => Ok(()),
Some(outcome) => Err(reply.get("reason").and_then(Value::as_str).unwrap_or(outcome).to_owned()),
},
Err(e) => Err(e.to_string()),
};
if let Err(e) = sent {
// The turn ended meanwhile: the message waits for the next one instead.
tracing::debug!("agent task {}: steering failed: {e}", self.id);
@@ -2098,10 +2122,10 @@ impl Task {
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
// Changing model can replace the effort choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
Some("model") => 0,
Some("thought_level") => 2,
// Changing model can replace the effort / context-tier choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id) {
Some(o) if o.category.as_deref() == Some("model") => 0,
Some(o) if config::is_per_model_option(o) => 2,
_ => 1,
});
let mut model_options = known;
@@ -2114,7 +2138,7 @@ impl Task {
}
continue;
}
if o.category.as_deref() == Some("thought_level") && !model_options {
if config::is_per_model_option(&o) && !model_options {
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
@@ -2131,7 +2155,7 @@ impl Task {
model_options = true;
} else if o.category.as_deref() == Some("model") {
if o.current.as_deref() != Some(choice.value.as_str()) {
options.retain(|o| o.category.as_deref() != Some("thought_level"));
options.retain(|o| !config::is_per_model_option(o));
}
model_options = false;
}
@@ -2195,14 +2219,17 @@ impl Task {
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut s.task.config, fresh);
} else if option.category.as_deref() == Some("model") {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
s.task.config.retain(|o| !config::is_per_model_option(o));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
// As in `set_config`: a copy without per-model options can't vouch for them.
let model = s.task.model.clone().filter(|_| {
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
reply.get("configOptions").is_some()
|| (option.category.as_deref() != Some("model")
&& s.task.config.iter().any(config::is_per_model_option))
});
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
drop(s);
@@ -2366,12 +2393,7 @@ impl Task {
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
Some(label) => format!("{label}: {title}"),
None => title,
};
let title = self.labelled(params, title);
let options: Vec<AgentPermissionOption> = params
.get("options")
.and_then(Value::as_array)
@@ -2403,6 +2425,7 @@ impl Task {
command: None,
options,
questions: Vec::new(),
form: None,
chosen: Some("Denied: side conversations are read-only".to_owned()),
});
return match reject {
@@ -2429,6 +2452,7 @@ impl Task {
command: shown.clone(),
options,
questions: Vec::new(),
form: None,
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
@@ -2449,6 +2473,7 @@ impl Task {
command: shown.clone(),
options,
questions: Vec::new(),
form: None,
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
@@ -2464,6 +2489,7 @@ impl Task {
command: shown,
options: options.clone(),
questions,
form: None,
chosen: None,
});
self.permissions.lock().insert(id, tx);
@@ -2490,17 +2516,7 @@ impl Task {
.and_then(|reply| options.iter().find(|option| &option.id == reply))
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
};
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
*chosen = Some(name);
}
}
self.mark_dirty([(None, entry)]);
let still_asking = !self.permissions.lock().is_empty();
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
self.settle(entry, name);
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
@@ -2532,6 +2548,57 @@ impl Task {
}
}
/// A request from a subagent is answered in the task's conversation, named after the subagent.
fn labelled(&self, params: &Value, title: String) -> String {
let session = params.get("sessionId").and_then(Value::as_str);
match session.and_then(|s| self.state.lock().conversation.label(s)) {
Some(label) => format!("{label}: {title}"),
None => title,
}
}
/// An agent's form (`elicitation/create`): asked in the conversation like a permission
/// request, and answered with its values, declined or cancelled. Blocks like `ask_permission`.
fn elicit(self: &Arc<Self>, params: &Value, raw: Option<&[u8]>) -> Result<Value, String> {
let mut form = elicitation::form_of(params)?;
if let Some(raw) = raw {
elicitation::in_schema_order(&mut form, raw);
}
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let message = params.get("message").and_then(Value::as_str).unwrap_or_default().trim().to_owned();
let (tx, rx) = std::sync::mpsc::channel();
let entry = self.push_entry(AgentEntry::Permission {
id,
title: self.labelled(params, message),
command: None,
options: elicitation::options(),
questions: Vec::new(),
form: Some(form.clone()),
chosen: None,
});
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
let reply = rx.recv().ok().flatten();
let (response, chosen) = elicitation::answer(&form, reply.as_deref());
self.settle(entry, chosen);
Ok(response)
}
/// Records the answer to the request at `entry`; the turn runs on once nothing else waits.
fn settle(self: &Arc<Self>, entry: usize, chosen: String) {
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen: answer, .. }) = s.conversation.main.entries.get_mut(entry) {
*answer = Some(chosen);
}
}
self.mark_dirty([(None, entry)]);
let still_asking = !self.permissions.lock().is_empty();
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
}
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
@@ -2596,9 +2663,18 @@ impl Handler for Client {
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned()))?;
match method {
"session/request_permission" => Ok(task.ask_permission(&params)),
"elicitation/create" => task.elicit(&params, None).map_err(|e| (codes::INVALID_PARAMS, e)),
_ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))),
}
}
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
if method != "elicitation/create" {
return self.on_request_with_data(method, params);
}
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned(), None))?;
task.elicit(&params, Some(raw)).map_err(|e| (codes::INVALID_PARAMS, e, None))
}
}
/// The prompt trimmed, or `None` when there is nothing to send.
@@ -2865,13 +2941,27 @@ fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<Agent
.collect()
}
/// The method that adds a message to the running turn: OpenIDEA's own, or the
/// `_session/steering` extension an agent advertises in the response's top-level `_meta`
/// (OMP).
fn steer_method(init: &acp::Negotiated) -> Option<&'static str> {
let supported = |v: Option<&Value>| v.and_then(Value::as_bool) == Some(true);
if supported(init.meta("/openidea/steer")) {
Some("_openidea/steer")
} else if supported(init.meta.as_ref().and_then(|m| m.pointer("/steering/supported"))) {
Some("_session/steering")
} else {
None
}
}
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
let capabilities = AgentCapabilities {
images: init.prompt("image"),
fork: init.session("fork"),
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
steer: steer_method(init).is_some(),
background_tasks: meta("/openidea/backgroundTasks"),
read_only: false,
};
+69
Переглянути файл
@@ -130,6 +130,14 @@ pub struct AgentConfigValue {
pub value: String,
pub name: String,
pub description: Option<String>,
/// Relative cost category from ACP `_meta` (`copilotPriceCategory`, e.g. `low`, `medium`, `high`, `very_high`).
#[serde(default)]
#[specta(optional)]
pub cost_category: Option<String>,
/// Usage multiplier from ACP `_meta` (`copilotUsage`, e.g. `1x`, `0x`, `3x`).
#[serde(default)]
#[specta(optional)]
pub usage_multiplier: Option<String>,
}
/// How a setting is changed.
@@ -155,6 +163,10 @@ pub struct AgentConfigOption {
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
pub category: Option<String>,
pub current: Option<String>,
/// Initial default value reported by the agent, preserved across session updates for Reset.
#[serde(default)]
#[specta(optional)]
pub default_value: Option<String>,
pub values: Vec<AgentConfigValue>,
pub via: AgentConfigVia,
}
@@ -316,6 +328,59 @@ pub struct AgentQuestionOption {
pub preview: Option<String>,
}
/// A form the agent asks the user to fill in (ACP `elicitation/create`, form mode).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentForm {
pub fields: Vec<AgentFormField>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentFormField {
/// The property name the answer is keyed by.
pub name: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
pub kind: AgentFormFieldKind,
/// The choices of a `choice` or `choices` field.
#[serde(default)]
pub options: Vec<AgentFormOption>,
/// The initial answer: option values, a text, a number, or `true` / `false`.
#[serde(default)]
pub default: Vec<String>,
/// Bounds: the number of a `number` / `integer`, the length of a `text`, the count of `choices`.
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentFormFieldKind {
Text,
Number,
Integer,
Boolean,
/// One of `options`.
Choice,
/// Any of `options`.
Choices,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentFormOption {
pub value: String,
pub label: String,
#[serde(default)]
pub description: 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")]
@@ -396,6 +461,10 @@ pub enum AgentEntry {
/// of question to answer instead of an option.
#[serde(default)]
questions: Vec<AgentQuestion>,
/// Set when the agent asks for a form (ACP elicitation); answered with a JSON object of
/// field name to its values, `decline`, or nothing to cancel.
#[serde(default)]
form: Option<AgentForm>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
+158
Переглянути файл
@@ -51,6 +51,9 @@ struct FakeAgent {
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
/// Advertises steering as OMP does (`_meta.steering` at the top of `initialize`)
/// instead of `_meta.openidea.steer`.
session_steering: bool,
}
impl FakeAgent {
@@ -156,6 +159,10 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
if self.session_steering {
return Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true},
"_meta": {"steering": {"supported": true}}}));
}
Ok(
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
@@ -183,6 +190,13 @@ impl Handler for FakeAgent {
self.calls.lock().push(format!("{method} {detail}"));
Ok(json!({}))
}
"_session/steering" => {
let text = params["prompt"][0]["text"].clone();
self.calls.lock().push(format!("{method} {text}"));
// OMP answers whether the message joined the turn rather than failing.
let outcome = if text == "too late" { "promptRequired" } else { "injected" };
Ok(json!({"outcome": outcome, "queue": "steering"}))
}
"session/load" => {
self.say(&session, "replayed history");
Ok(json!({}))
@@ -281,6 +295,23 @@ impl Handler for FakeAgent {
self.say(&session, &format!("escalation chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "form" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"elicitation/create",
json!({"sessionId": session, "mode": "form", "message": "How should I refactor?",
"requestedSchema": {"type": "object", "required": ["strategy"], "properties": {
"strategy": {"type": "string", "title": "Strategy",
"oneOf": [{"const": "safe", "title": "Conservative"},
{"const": "bold", "title": "Aggressive"}]},
"tests": {"type": "boolean", "title": "Run tests"}}}}),
None,
))
.unwrap();
self.say(&session, &format!("form {answer}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -1060,6 +1091,42 @@ fn text(t: &str) -> AgentPrompt {
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
}
#[tokio::test(flavor = "multi_thread")]
async fn steers_through_session_steering() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { session_steering: true, ..FakeAgent::default() });
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Running).await;
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.steer);
mgr.prompt(&task.id, text("also this"), true).await.unwrap();
assert!(agent.calls.lock().iter().any(|c| c == "_session/steering \"also this\""));
let entries = mgr.entries(&task.id, None).unwrap();
assert!(matches!(entries.last(), Some(AgentEntry::User { steered: true, .. })));
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
// The agent didn't take it into the turn: the turn stops and it goes next, as without
// steering (the fake agent's next turn asks for permission).
mgr.prompt(&task.id, text("too late"), true).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: false, .. } if text == "too late")));
assert!(!entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: true, .. } if text == "too late")));
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let tmp = tempfile::tempdir().unwrap();
@@ -1728,6 +1795,45 @@ async fn thinking_levels_are_remembered_per_provider_and_model() {
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn changing_an_idle_tasks_settings_keeps_its_models_effort_levels() {
// After a model switch no session reported, picking that model again or changing
// another setting must not record the task's effort-less copy as the model's levels.
for (option, value) in [("mode", "code"), ("model", "big")] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "high")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
let restored = manager(tmp.path(), agent);
restored.set_config(&task.id, "model", "small".into()).await.unwrap();
restored.set_config(&task.id, "model", "big".into()).await.unwrap();
restored.set_config(&task.id, option, value.into()).await.unwrap();
let p = restored.providers().into_iter().find(|p| p.id == "claude").unwrap();
let big = p.thought_levels.get("big").and_then(|levels| levels.first());
assert_eq!(big.map(|o| o.values.len()), Some(2), "{option}: {:?}", p.thought_levels);
let saved: HashMap<String, Known> =
serde_json::from_slice(&std::fs::read(&restored.shared.paths.models_file).unwrap()).unwrap();
assert_eq!(saved["claude"].thought_levels, p.thought_levels, "{option}");
restored.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
for (effort, applied) in [("medium", true), ("high", false)] {
@@ -1985,6 +2091,58 @@ async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permis
}
}
#[tokio::test(flavor = "multi_thread")]
async fn an_agent_form_is_answered_declined_or_cancelled_even_when_approvals_are_bypassed() {
let accept = json!({"strategy": ["bold"], "tests": ["true"]}).to_string();
for (reply, said, chosen) in [
(
Some(accept.as_str()),
r#"form {"action":"accept","content":{"strategy":"bold","tests":true}}"#,
"Strategy: Aggressive; Run tests: yes",
),
(Some("decline"), r#"form {"action":"decline"}"#, "Decline"),
(None, r#"form {"action":"cancel"}"#, "Cancelled"),
] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("form"),
provider: "cursor".into(),
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, form) = mgr
.entries(&task.id, None)
.unwrap()
.into_iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, title, form: Some(form), chosen: None, .. }
if title == "How should I refactor?" =>
{
Some((id, form))
}
_ => None,
})
.unwrap();
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests"]);
mgr.respond(&task.id, id, reply.map(str::to_owned)).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::Agent { text } if text == said)), "{entries:?}");
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == chosen)),
"{entries:?}"
);
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -441,6 +441,7 @@ mod tests {
command: None,
options: vec![],
questions: vec![],
form: None,
chosen: None,
});
assert_eq!(t.end_turn(None), vec![2, 3]);
+8 -4
Переглянути файл
@@ -218,16 +218,20 @@ where
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// The built-in themes that were removed (#2981; the One Dark Pro plugin
/// maintains their look) and the built-in that stands in for each: read by
/// [`theme_id`], and the removal notice looks for them in settings files.
pub const REMOVED_THEMES: [(&str, &str); 2] = [("one-light", "light"), ("one-dark", "dark")];
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = String::deserialize(d)?;
Ok(match id.as_str() {
"one-light" => "light".into(),
"one-dark" => "dark".into(),
_ => id,
Ok(match REMOVED_THEMES.iter().find(|(removed, _)| *removed == id.as_str()) {
Some((_, stand_in)) => (*stand_in).to_owned(),
None => id,
})
}
+2
Переглянути файл
@@ -19,6 +19,7 @@ pub enum Tool {
Maven,
Gradle,
Ruby,
Zig,
}
impl Tool {
@@ -32,6 +33,7 @@ impl Tool {
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
Tool::Zig => &["zig"],
}
}
}
+21
Переглянути файл
@@ -48,6 +48,9 @@ pub enum Capability {
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Send a running language server (its name in the language definition) its own
/// requests. The server reads and edits project files on the plugin's behalf.
Lsp(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
@@ -85,6 +88,7 @@ impl Capability {
| Self::IdeCommands
| Self::Ipc(_)
| Self::Process(_)
| Self::Lsp(_)
| Self::FsRead(_)
| Self::FsWrite(_) => Risk::High,
}
@@ -126,6 +130,9 @@ impl Capability {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::Lsp(server) => {
format!("Send requests to the {server} language server, which reads and changes the project's files")
}
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
@@ -249,6 +256,7 @@ impl fmt::Display for Capability {
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::Lsp(s) => write!(f, "lsp:{s}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
@@ -298,6 +306,12 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"lsp" => {
if arg.len() > 128 || arg.chars().any(|c| c.is_whitespace() || c.is_control() || c == ':') {
return Err(format!("{s:?}: use the server's name in its language definition"));
}
Ok(Self::Lsp(arg.to_owned()))
}
"fs.read" | "fs.write" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
@@ -376,6 +390,8 @@ mod tests {
"net:crates.io",
"net:*.github.com",
"process:cargo",
"lsp:phoxite",
"lsp:rust-analyzer",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
@@ -392,6 +408,9 @@ mod tests {
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("lsp:".parse::<Capability>().is_err());
assert!("lsp:a b".parse::<Capability>().is_err());
assert!("lsp:phoxite:phoxite/laravel".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
@@ -405,6 +424,8 @@ mod tests {
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert_eq!(Capability::Lsp("phoxite".into()).risk(), Risk::High);
assert!(!Capability::Lsp("phoxite".into()).covers(&Capability::Lsp("intelephense".into())));
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
+18
Переглянути файл
@@ -357,6 +357,9 @@ impl Manifest {
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
if matches!(cap, Capability::Lsp(_)) {
self.needs_api(&format!("capability `{cap}`"), 9)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -712,6 +715,21 @@ mod tests {
}
}
#[test]
fn language_server_capabilities_need_api_1_9() {
let caps = "[capabilities]
required = [\"lsp:phoxite\"]
";
for engine in ["^1.2", "^1.8"] {
assert!(
err(&format!("{}{caps}", BASE.replace("^1.2", engine))).contains("new in plugin API 1.9"),
"{engine}"
);
}
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.9"))).unwrap();
assert_eq!(m.capabilities.required, [Capability::Lsp("phoxite".into())]);
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
+9
Переглянути файл
@@ -65,6 +65,15 @@ pub fn settings(host: &Host, window: &str) -> Settings {
})
}
/// The removed built-in theme a settings file of this window still names, if any
/// (`settings` reports its stand-in): the "provided by a plugin now" notice targets these.
pub fn settings_removed_theme(host: &Host, window: &str) -> Option<String> {
match host.project(window) {
Ok(p) => p.settings.named_removed_theme().or_else(|| host.global_settings.named_removed_theme()),
Err(_) => host.global_settings.named_removed_theme(),
}
}
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
Ok(host.project(window)?.roots())
}
+72
Переглянути файл
@@ -97,6 +97,21 @@ pub trait PluginWorkspace: Send + Sync {
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Sends `method` to the running language server named `server` of the project at
/// `root`: a request, or with `notify` a notification (the result is null).
#[allow(clippy::too_many_arguments)]
fn lsp_request(
&self,
_root: &Path,
server: &str,
_method: &str,
_params: Option<serde_json::Value>,
_notify: bool,
_cancel: &CancellationToken,
_timeout: Duration,
) -> Result<serde_json::Value, HostError> {
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
}
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
fn symbols(
&self,
@@ -2180,6 +2195,35 @@ impl HostApi for PluginApi {
)
}
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<String>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
self.require_api("`language-servers`", 9)?;
if !ide_lsp::plugin_method_allowed(method) {
return Err(HostError::Invalid(format!("`{method}` is not one of the server's own methods")));
}
let workspace = self
.shared
.workspace()
.ok_or_else(|| HostError::NotFound(format!("no language server `{server}` is running for this project")))?;
// The server reads and edits the project on the plugin's behalf, as a process would.
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
let params = params
.map(|p| serde_json::from_str(&p).map_err(|e| HostError::Invalid(format!("params are not JSON: {e}"))))
.transpose()?;
let result = workspace.lsp_request(root, server, method, params, notify, cancel, timeout)?;
Ok(result.to_string())
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
@@ -3302,6 +3346,34 @@ impl PluginWorkspace for HostWorkspace {
}
}
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<serde_json::Value>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<serde_json::Value, HostError> {
use ide_core::error::ErrorKind;
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
let project = HostWorkspace::project_covering(self, root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let result = if notify {
project.lsp.server_notify(root, server, method, params).map(|()| serde_json::Value::Null)
} else {
host.runtime.block_on(project.lsp.server_request(root, server, method, params, cancel, timeout))
};
result.map_err(|e| match e.kind {
// A server that stopped while the request was on its way is as absent as one never started.
ErrorKind::NotFound | ErrorKind::Unavailable => HostError::NotFound(e.message),
ErrorKind::InvalidInput => HostError::Invalid(e.message),
ErrorKind::Cancelled => HostError::Cancelled,
_ => HostError::Failed(e.message),
})
}
fn trusted(&self, root: &Path) -> bool {
// A workspace member is as trusted as the group's project it was opened with.
HostWorkspace::project_covering(self, root).is_some_and(|p| p.is_trusted())
+3 -1
Переглянути файл
@@ -1275,7 +1275,9 @@ impl Project {
}
let defaults = self.documents.file_defaults();
let fallback = defaults.fallback_for(path);
if path.exists() {
// The working file only when it is a regular file: a path that is a directory there
// (a committed symlink to a folder, a file replaced by a folder) has only its blob.
if path.is_file() {
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
+1
Переглянути файл
@@ -165,6 +165,7 @@ impl Dispatcher {
})
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
.add("settings_removed_theme", |c| async move { Ok(ops::project::settings_removed_theme(&c.host, &c.window)) })
.add("settings_set", |c| async move {
let key: String = c.arg("key")?;
let scope = c.arg("scope")?;
+1
Переглянути файл
@@ -408,6 +408,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "settings_export"
| "settings_get"
| "settings_get_global"
| "settings_removed_theme"
| "settings_import"
| "settings_list_jetbrains_ides"
| "settings_read_jetbrains"
+85
Переглянути файл
@@ -31,6 +31,8 @@ struct TestWorkspace {
/// plugin → its settings as the user set them.
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
/// What plugins sent the language server `mock`: (method, params, whether a notification).
server_messages: Mutex<Vec<(String, Option<serde_json::Value>, bool)>>,
}
impl TestWorkspace {
@@ -49,6 +51,7 @@ impl TestWorkspace {
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
server_messages: Mutex::default(),
})
}
@@ -176,6 +179,23 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
/// One language server runs for the project, `mock`: it answers with what it was sent.
fn lsp_request(
&self,
_root: &Path,
server: &str,
method: &str,
params: Option<serde_json::Value>,
notify: bool,
_cancel: &ide_core::CancellationToken,
_timeout: Duration,
) -> Result<serde_json::Value, HostError> {
if server != "mock" {
return Err(HostError::NotFound(format!("no language server `{server}` is running for this project")));
}
self.server_messages.lock().push((method.into(), params.clone(), notify));
Ok(if notify { serde_json::Value::Null } else { serde_json::json!({ "method": method, "params": params }) })
}
fn project_windows(&self, root: &Path) -> Vec<String> {
// `main` shows the fixture project; other open roots have no window left.
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
@@ -2606,6 +2626,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
server_messages: Mutex::default(),
});
f.host.set_workspace(Some(untrusted));
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
@@ -6930,3 +6951,67 @@ fn project_write_judges_a_link_by_its_target() {
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "top secret", "{rel}");
}
}
/// A plugin reaches a running language server only with `lsp:<server>`, in a trusted
/// project, and only with the server's own methods (API 1.9).
#[test]
fn language_server_requests_need_the_servers_grant_and_its_own_methods() {
use serde_json::json;
let f = fixture(Limits::default());
let pkg = repo().join("crates/ide-plugin/tests/fixtures/lsp-requests");
let id = "test.lsp-requests";
let info = f.install(&pkg);
assert!(info.capabilities.iter().any(|c| c.id == "lsp:mock" && c.granted && c.risk == ide_extension::Risk::High));
// The request and its JSON params reach the server; its answer comes back as JSON.
let answer = f.run(id, "request", Some(r#"{"verb":"GET"}"#)).unwrap().unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&answer).unwrap(),
json!({ "method": "mock/routes", "params": { "verb": "GET" } })
);
assert_eq!(f.run(id, "notify", None).unwrap(), None);
assert_eq!(
*f.workspace.server_messages.lock(),
[("mock/routes".to_owned(), Some(json!({ "verb": "GET" })), false), ("mock/changed".to_owned(), None, true)]
);
// Params that are not JSON and LSP's own methods stop at the host.
assert!(f.run(id, "request", Some("{")).unwrap_err().contains("params are not JSON"));
assert!(f.run(id, "standard", None).unwrap_err().contains("not one of the server's own methods"));
assert_eq!(f.workspace.server_messages.lock().len(), 2);
// The grant names one server: another one is denied, not merely absent.
let err = f.host.run_command(id, "ungranted", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("lsp:other"), "{err:?}");
// Without the grant the call is denied and nothing is sent.
f.host.set_capability(id, "lsp:mock", false).unwrap();
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("lsp:mock"), "{err:?}");
f.host.set_capability(id, "lsp:mock", true).unwrap();
f.run(id, "request", None).unwrap();
// A server acts on the project's files: not in an untrusted project.
let mut untrusted = TestWorkspace::new(&f.project);
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
f.host.set_workspace(Some(untrusted.clone()));
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(untrusted.server_messages.lock().is_empty());
assert_eq!(f.host.info_of(id).unwrap().stats.crashes, 0);
}
/// The grant is new in API 1.9: an older `engines.openidea` can't ask for it.
#[test]
fn the_language_server_grant_needs_api_1_9() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("lsp-requests");
copy_dir(&repo().join("crates/ide-plugin/tests/fixtures/lsp-requests"), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.9\"", "\"^1.8\"")).unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.9"), "{err:?}");
}
+46
Переглянути файл
@@ -111,3 +111,49 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
host.close_project("main");
}
/// A committed symlink to a folder (#3528): its revision is the link target, even though the
/// working path is a directory.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_directory_symlink_revision_is_its_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(root.join("target")).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("target/file.txt"), "example\n").unwrap();
std::os::unix::fs::symlink("target", root.join("dir-link")).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "Add a directory symlink"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let link = root.join("dir-link");
assert!(link.is_dir());
assert_eq!(
vcs::vcs_file_at(&host, "main", link.clone(), Some("HEAD".into())).await.unwrap().as_deref(),
Some("target")
);
assert_eq!(vcs::vcs_file_mode(&host, "main", link, Some("HEAD".into())).await.unwrap().as_deref(), Some("120000"));
host.close_project("main");
}
+25 -11
Переглянути файл
@@ -78,8 +78,8 @@ impl Needle {
let mut b = 0;
if n.eq_ignore_ascii_case(q) {
b += EXACT;
} else if let Some(dot) = name.find('.')
&& name.as_bytes()[..dot].eq_ignore_ascii_case(q)
} else if let Some(dot) = n.iter().position(|&b| b == b'.')
&& n[..dot].eq_ignore_ascii_case(q)
{
b += EXACT_STEM;
} else if n.len() >= q.len() && n[..q.len()].eq_ignore_ascii_case(q) {
@@ -97,17 +97,31 @@ fn hump_bonus(s: &str, indices: &[u32]) -> u32 {
if indices.is_empty() {
return 0;
}
let chars: Vec<char> = s.chars().collect();
let is_start = |i: usize| {
if i == 0 {
return true;
// One pass over `s`: `indices` is sorted, and whether position `i` is a
// word start only needs the char before it, so no `Vec<char>` is collected.
let is_start = |prev: Option<char>, c: char| match prev {
None => true,
Some(p) => {
!p.is_alphanumeric()
|| (p.is_lowercase() && c.is_uppercase())
|| (!p.is_ascii_digit() && c.is_ascii_digit())
}
let (p, c) = (chars[i - 1], chars[i]);
!p.is_alphanumeric() || (p.is_lowercase() && c.is_uppercase()) || (!p.is_ascii_digit() && c.is_ascii_digit())
};
let humps = indices.iter().filter(|&&i| (i as usize) < chars.len() && is_start(i as usize)).count() as u32;
let first = if is_start(indices[0] as usize) { FIRST_AT_START } else { 0 };
humps * HUMP + first
let (mut humps, mut next, mut first_start) = (0u32, 0usize, false);
let (mut prev, mut at) = (None, 0u32);
for c in s.chars() {
if next < indices.len() && indices[next] == at {
let started = is_start(prev, c);
humps += u32::from(started);
if next == 0 {
first_start = started;
}
next += 1;
}
prev = Some(c);
at += 1;
}
humps * HUMP + u32::from(first_start) * FIRST_AT_START
}
#[derive(Clone, Copy)]
+21 -8
Переглянути файл
@@ -215,12 +215,18 @@ pub trait Handler: Send + Sync + 'static {
/// Respond to a server→client request (e.g. `workspace/configuration`).
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)>;
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`. This is the
/// one the connection calls.
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`.
fn on_request_with_data(&self, method: &str, params: Value) -> Result<Value, (i64, String, Option<Value>)> {
self.on_request(method, params).map_err(|(code, message)| (code, message, None))
}
/// [`on_request_with_data`](Self::on_request_with_data) with the request as received, for
/// handlers that need what `Value` drops, such as the order of an object's keys. This is the
/// one the connection calls.
fn on_request_raw(&self, method: &str, params: Value, _raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
self.on_request_with_data(method, params)
}
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
@@ -577,8 +583,8 @@ impl Peer {
tracing::error!("jsonrpc notification handler panicked on {method}");
}
}
Incoming::Request(id, method, params) => {
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
Incoming::Request(id, method, params, raw) => {
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params, raw));
}
Incoming::Response(tx, result) => {
let _ = tx.send(result);
@@ -761,14 +767,21 @@ fn parse_id(id: &Value) -> Option<i64> {
enum Incoming {
Notification(String, Value),
Request(Value, String, Value),
Request(Value, String, Value, Arc<[u8]>),
Response(oneshot::Sender<Result<Value, RpcError>>, Result<Value, RpcError>),
}
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
async fn handle_request(
inner: Arc<Inner>,
handler: Arc<dyn Handler>,
id: Value,
method: String,
params: Value,
raw: Arc<[u8]>,
) {
let m = method.clone();
let h = handler.clone();
let result = tokio::task::spawn_blocking(move || h.on_request_with_data(&m, params)).await;
let result = tokio::task::spawn_blocking(move || h.on_request_raw(&m, params, &raw)).await;
let reply = match result {
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Ok(Err((code, message, data))) => {
@@ -808,7 +821,7 @@ fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mps
let params = obj.remove("params").unwrap_or(Value::Null);
match id {
Some(id) if !id.is_null() => {
let _ = notif_tx.send(Incoming::Request(id, method, params));
let _ = notif_tx.send(Incoming::Request(id, method, params, Arc::from(body)));
}
_ => {
let _ = notif_tx.send(Incoming::Notification(method, params));
+5
Переглянути файл
@@ -170,6 +170,11 @@ pub struct LanguageServerConfig {
/// basedpyright): only used while none of those is installed.
#[serde(default)]
pub fallback: bool,
/// Names of servers listed after this one that it makes redundant (a PHP server
/// with its own Laravel support replaces `laravel-lsp`): they aren't used while
/// this one serves the file.
#[serde(default)]
pub replaces: Vec<String>,
/// What the server is used for (`completion`, `code-action`, `diagnostics`,
/// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other
/// capabilities it announces are ignored, so a refactoring-only server next
+4 -1
Переглянути файл
@@ -758,7 +758,10 @@ mod tests {
let cfg = reg.get(&LanguageId::new("php")).unwrap();
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
// After the fallback: hover, rename and symbols stay with Intelephense.
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
assert_eq!(names, ["intelephense", "phpactor", "phoxite", "laravel-lsp"]);
// Phoxite answers the Laravel strings itself.
let phoxite = cfg.language_servers.iter().find(|s| s.name == "phoxite").unwrap();
assert!(phoxite.fallback && phoxite.replaces == ["laravel-lsp"]);
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
assert_eq!(lsp.command.as_str(), "laravel-lsp");
// Its `lsp` command takes no options and speaks over stdio; `--stdio` stops it at startup.
+28 -9
Переглянути файл
@@ -26,6 +26,7 @@ mod launch;
mod library_source;
mod loading;
mod notebook;
mod plugin_requests;
mod pull;
mod scope;
mod server;
@@ -45,6 +46,7 @@ pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
pub use plugin_requests::plugin_method_allowed;
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -660,15 +662,17 @@ impl LspManager {
/// The servers serving `path`: every regular one, and each `fallback` one
/// only while all servers listed before it have given up (missing, not
/// launchable, or crashed for good). One that requires a root marker serves
/// only files below one, and stands in for no other.
/// only files below one, and stands in for no other. One that a server before
/// it `replaces` is left out while that server serves the file.
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
if let Some(servers) = self.arduino_servers(language, path) {
return servers;
}
let mut out = Vec::new();
let mut available_before = false;
let mut replaced: Vec<String> = Vec::new();
for sc in &self.language_servers(language) {
if sc.fallback && available_before {
if (sc.fallback && available_before) || replaced.contains(&sc.name) {
continue;
}
if sc.require_root_marker
@@ -678,6 +682,9 @@ impl LspManager {
}
let server = self.server_for(sc, language, path);
available_before |= !sc.require_root_marker && !server.gave_up();
if !server.gave_up() {
replaced.extend(sc.replaces.iter().cloned());
}
out.push(server);
}
out
@@ -865,7 +872,7 @@ impl LspManager {
ide_core::event::ServerStatus::Missing,
Some(message),
) {
self.attach_fallbacks();
self.fallbacks_take_over().await;
}
return;
}
@@ -878,7 +885,7 @@ impl LspManager {
ide_core::event::ServerStatus::Misconfigured,
Some(message),
) {
self.attach_fallbacks();
self.fallbacks_take_over().await;
}
return;
}
@@ -891,7 +898,7 @@ impl LspManager {
ide_core::event::ServerStatus::Crashed,
Some(message),
) {
self.attach_fallbacks();
self.fallbacks_take_over().await;
}
return;
}
@@ -1039,7 +1046,7 @@ impl LspManager {
}
}
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
None => self.attach_fallbacks(),
None => self.fallbacks_take_over().await,
}
}
@@ -3464,7 +3471,10 @@ impl LspManager {
virtual_doc::is_decompiled(&text)
};
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
let detected = self.languages.detect(Path::new(&title), None);
let mut detected = self.languages.detect(Path::new(&title), None);
if let Some(stem) = title.strip_suffix(".class").filter(|_| virtual_doc::is_java_class_stub(&text)) {
detected = self.languages.detect(Path::new(&format!("{stem}.java")), None);
}
let language_id = if detected == LanguageId::plain_text() {
languages.first().cloned().unwrap_or(detected)
} else {
@@ -3828,8 +3838,16 @@ impl LspManager {
}
}
/// A server enabled again shadows the fallbacks attached in its place: they
/// leave the open documents, and stop once no document uses them.
/// A server gave up: its fallbacks take over the open documents, and the servers
/// they replace leave.
async fn fallbacks_take_over(&self) {
self.attach_fallbacks();
self.detach_shadowed_fallbacks().await;
}
/// A server enabled again shadows the fallbacks attached in its place, and a
/// fallback that took over may replace a server attached earlier: they leave the
/// open documents, and stop once no document uses them.
async fn detach_shadowed_fallbacks(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
@@ -3845,6 +3863,7 @@ impl LspManager {
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| configs.iter().any(|c| c.replaces.contains(&a.server.name))
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
})
.map(|a| a.server.id)
+119
Переглянути файл
@@ -0,0 +1,119 @@
//! Requests a plugin sends to a running server (`language-servers` in the plugin API):
//! the server's own methods only, and only to a server that already runs.
use crate::{LspManager, Target, rpc_err};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde_json::Value;
use std::path::Path;
use std::time::Duration;
/// The first segment of the methods LSP itself defines.
const LSP_NAMESPACES: [&str; 14] = [
"textDocument",
"workspace",
"window",
"notebookDocument",
"callHierarchy",
"typeHierarchy",
"completionItem",
"codeAction",
"codeLens",
"documentLink",
"inlayHint",
"workspaceSymbol",
"client",
"telemetry",
];
const LIFECYCLE: [&str; 4] = ["initialize", "initialized", "shutdown", "exit"];
/// Whether a plugin may send `method`: only a server's own namespace passes
/// (`phoxite/laravel/routes`), so a plugin can't interfere with document sync, the
/// features the IDE asks for itself, or the server's lifecycle.
pub fn plugin_method_allowed(method: &str) -> bool {
let namespace = method.split('/').next().unwrap_or_default();
!(method.is_empty()
|| method.starts_with("$/")
|| LIFECYCLE.contains(&method)
|| LSP_NAMESPACES.contains(&namespace))
}
impl LspManager {
/// The running server named `server`, the one rooted at `root` first. Never starts one.
fn plugin_target(&self, root: &Path, server: &str, method: &str) -> IdeResult<Target> {
if !plugin_method_allowed(method) {
return Err(IdeError::invalid(format!("`{method}` is not one of the server's own methods")));
}
let mut running: Vec<Target> = self
.servers
.lock()
.iter()
.filter(|s| s.name == server)
.filter_map(|s| s.running().map(|run| Target { server: s.clone(), run }))
.collect();
let at = running.iter().position(|t| t.server.root == root).unwrap_or(0);
if running.is_empty() {
return Err(IdeError::not_found(format!("no language server `{server}` is running for this project")));
}
Ok(running.swap_remove(at))
}
/// Sends a plugin's request to the running server named `server` (its name in the
/// language definition) and returns the result. `not_found` when it is not running,
/// `invalid` for a method that is not the server's own.
pub async fn server_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<Value>,
cancel: &CancellationToken,
timeout: Duration,
) -> IdeResult<Value> {
let t = self.plugin_target(root, server, method)?;
self.call(&t, method, params.unwrap_or(Value::Null), Some(cancel), timeout).await
}
/// The same for a notification.
pub fn server_notify(&self, root: &Path, server: &str, method: &str, params: Option<Value>) -> IdeResult<()> {
let t = self.plugin_target(root, server, method)?;
t.run.peer.notify(method, params.unwrap_or(Value::Null)).map_err(|e| rpc_err(server, method, e))
}
}
#[cfg(test)]
mod tests {
use super::plugin_method_allowed;
#[test]
fn only_a_servers_own_methods_pass() {
for method in ["phoxite/laravel/routes", "phoxite/refactor/hierarchy", "rust-analyzer/expandMacro", "custom"] {
assert!(plugin_method_allowed(method), "{method}");
}
for method in [
"",
"initialize",
"initialized",
"shutdown",
"exit",
"$/cancelRequest",
"$/progress",
"textDocument/hover",
"textDocument/didChange",
"workspace/executeCommand",
"window/showMessage",
"notebookDocument/didOpen",
"callHierarchy/incomingCalls",
"typeHierarchy/supertypes",
"completionItem/resolve",
"codeAction/resolve",
"codeLens/resolve",
"documentLink/resolve",
"inlayHint/resolve",
"workspaceSymbol/resolve",
"client/registerCapability",
"telemetry/event",
] {
assert!(!plugin_method_allowed(method), "{method}");
}
}
}
+22
Переглянути файл
@@ -110,6 +110,17 @@ pub fn is_decompiled(text: &str) -> bool {
})
}
/// Whether a `.class` stub is written in Java: its first `package` or `import` line ends with `;`.
/// The Kotlin language server shows Java classes from jars as Java and Kotlin ones as Kotlin, under
/// the same `.class` name.
pub fn is_java_class_stub(text: &str) -> bool {
text.lines()
.take(200)
.map(str::trim)
.find(|line| line.starts_with("package ") || line.starts_with("import "))
.is_some_and(|line| line.ends_with(';'))
}
/// `scheme` of a URI (`jdt` for `jdt://…`).
pub fn scheme(uri: &str) -> &str {
uri.split_once(':').map(|(s, _)| s).unwrap_or("")
@@ -205,6 +216,17 @@ mod tests {
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
#[test]
fn class_stubs_in_java_or_kotlin() {
let java = "// IntelliJ API Decompiler stub source generated from a class file\n\npackage com.boydti.fawe.util;\n\npublic class EditSessionBuilder {\n}\n";
let kotlin = "// IntelliJ API Decompiler stub source generated from a class file\n// Implementation of methods is not available\n\npackage kotlin.collections\n\npublic fun <T> listOf(): List<T> { /* compiled code */ }\n";
let imports_only = "import java.util.List;\n\npublic class A {}\n";
assert!(is_java_class_stub(java));
assert!(is_java_class_stub(imports_only));
assert!(!is_java_class_stub(kotlin));
assert!(!is_java_class_stub("public class A {}"));
}
#[test]
fn csharp_metadata_titles_and_kinds() {
for (uri, expected) in [
+118
Переглянути файл
@@ -264,6 +264,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
install_hint: Some(format!("install {name}")),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -3286,6 +3287,67 @@ async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
h.wait_status("alt", ServerStatus::Ready).await;
}
/// A server that `replaces` another (a PHP server with its own Laravel support and
/// `laravel-lsp`) keeps it away while it serves, and only then.
#[tokio::test(flavor = "multi_thread")]
async fn a_replaced_server_stays_away_while_its_replacement_serves() {
let servers = [("main", incremental_caps()), ("alt", incremental_caps()), ("framework", incremental_caps())];
let configure = |cfg: &mut LanguageServerConfig| match cfg.name.as_str() {
"alt" => {
cfg.fallback = true;
cfg.replaces = vec!["framework".into()];
}
"framework" => cfg.require_root_marker = true,
_ => {}
};
let h = harness_configured("mock", "mk", &servers, configure);
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
{
let disabled = disabled.clone();
h.mgr.set_servers_hook(Box::new(move |_, defined| {
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
}));
}
std::fs::write(h.root.join("mock.toml"), "").unwrap();
let (_, path) = h.open("a.mk", "x");
let uri = uri_of(&path);
// The preferred server serves: the framework's server joins it, the alternative stays away.
h.wait_status("main", ServerStatus::Ready).await;
h.wait_status("framework", ServerStatus::Ready).await;
eventually(|| h.launcher.state("framework").docs.lock().contains_key(&uri)).await;
assert!(h.statuses("alt").is_empty());
// The alternative takes over: the server it replaces closes the document and stops.
disabled.lock().push("main".into());
h.mgr.reconfigure().await;
h.wait_status("alt", ServerStatus::Ready).await;
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
assert!(!h.launcher.state("framework").notified("textDocument/didClose").is_empty());
assert_eq!(h.statuses("framework").last(), Some(&ServerStatus::Stopped));
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["alt"]);
// The preferred server again: the framework's server comes back with it.
disabled.lock().clear();
h.mgr.reconfigure().await;
h.wait_status("main", ServerStatus::Ready).await;
eventually(|| h.statuses("framework").last() == Some(&ServerStatus::Ready)).await;
assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped));
// A preferred server that is not installed: the alternative replaces the framework's
// server although that one attached first.
let servers = [("ghost", json!({})), ("alt", incremental_caps()), ("framework", incremental_caps())];
let h = harness_configured("mock", "mk", &servers, configure);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
std::fs::write(h.root.join("mock.toml"), "").unwrap();
let (_, path) = h.open("a.mk", "x");
let uri = uri_of(&path);
h.wait_status("ghost", ServerStatus::Missing).await;
h.wait_status("alt", ServerStatus::Ready).await;
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
eventually(|| !h.mgr.servers().iter().any(|s| s.name == "framework")).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() {
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
@@ -7310,3 +7372,59 @@ async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
.await;
h.mgr.shutdown_all().await;
}
/// A plugin's request (`language-servers` in the plugin API) reaches the running server
/// named in the language definition: only the server's own methods, and never a start.
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_request_reaches_only_a_running_servers_own_methods() {
let servers = [("main", incremental_caps()), ("alt", incremental_caps())];
let h = harness_for("mock", "mk", &servers, &["alt"]);
let _ = h.open("a.mk", "x");
h.wait_status("main", ServerStatus::Ready).await;
let s = h.launcher.state("main");
s.respond("mock/routes", json!([{ "uri": "/users", "name": "users.index" }]));
let cancel = CancellationToken::new();
let timeout = Duration::from_secs(5);
// A request: the params arrive, the result comes back.
let routes = h
.mgr
.server_request(&h.root, "main", "mock/routes", Some(json!({ "method": "GET" })), &cancel, timeout)
.await
.unwrap();
assert_eq!(routes, json!([{ "uri": "/users", "name": "users.index" }]));
assert_eq!(s.requested("mock/routes"), [json!({ "method": "GET" })]);
// A notification.
h.mgr.server_notify(&h.root, "main", "mock/changed", Some(json!({ "what": "routes" }))).unwrap();
eventually(|| s.notified("mock/changed") == [json!({ "what": "routes" })]).await;
// LSP's own methods are refused before anything is sent.
for method in ["textDocument/hover", "workspace/executeCommand", "$/cancelRequest", "shutdown", "initialize"] {
let before = (s.requested(method).len(), s.notified(method).len());
let e = h.mgr.server_request(&h.root, "main", method, None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{method}");
assert_eq!(
h.mgr.server_notify(&h.root, "main", method, None).unwrap_err().kind,
ide_core::error::ErrorKind::InvalidInput
);
assert_eq!((s.requested(method).len(), s.notified(method).len()), before, "{method}");
}
// A server that is defined but not running is not started; an unknown name is the same.
for server in ["alt", "nobody"] {
let e = h.mgr.server_request(&h.root, server, "mock/routes", None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::NotFound, "{server}");
assert_eq!(
h.mgr.server_notify(&h.root, server, "mock/changed", None).unwrap_err().kind,
ide_core::error::ErrorKind::NotFound
);
}
assert_eq!(h.launcher.state("alt").launches.load(Ordering::SeqCst), 0);
assert!(h.statuses("alt").is_empty());
// A cancelled invocation ends the request.
cancel.cancel();
let e = h.mgr.server_request(&h.root, "main", "mock/routes", None, &cancel, timeout).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::Cancelled);
}
+3
Переглянути файл
@@ -419,6 +419,7 @@ async fn missing_binary_is_reported_with_install_hint() {
install_hint: Some("npm i -g nope-ls".into()),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -692,6 +693,7 @@ async fn typescript_language_server_end_to_end() {
install_hint: None,
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
@@ -745,6 +747,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
install_hint: None,
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
+17
Переглянути файл
@@ -100,6 +100,23 @@ pub trait HostApi: Send + Sync {
let _ = (root, client, id, cancel, timeout);
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
}
/// Sends `method` to the running language server named `server` of the project at
/// `root`: a request (the result as JSON), or with `notify` a notification (`null`).
/// `params` is JSON. Never starts a server.
#[allow(clippy::too_many_arguments)]
fn lsp_request(
&self,
root: &Path,
server: &str,
method: &str,
params: Option<String>,
notify: bool,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<String, HostError> {
let _ = (root, method, params, notify, cancel, timeout);
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
}
/// Stdio argv only; no URL or bearer token.
fn mcp_stdio_command(&self, client: &str) -> Option<Vec<String>> {
let _ = client;
+54
Переглянути файл
@@ -59,6 +59,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -97,6 +98,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -135,6 +137,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/output": super::bindings::openidea::plugin::output,
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
@@ -2648,6 +2651,57 @@ impl bindings::openidea::plugin::database::Host for StoreData {
Ok(result.map_err(to_wit))
}
}
/// The longest method name and params a plugin may send a language server.
const MAX_LSP_METHOD: usize = 256;
const MAX_LSP_PARAMS: usize = 1 << 20;
impl StoreData {
fn lsp_call(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
notify: bool,
) -> Fallible<String> {
if let Err(e) = self.require(Capability::Lsp(server.clone())) {
return Ok(Err(e));
}
if method.len() > MAX_LSP_METHOD || params.as_ref().is_some_and(|p| p.len() > MAX_LSP_PARAMS) {
return Ok(Err(wit::Error::Invalid("method exceeds 256 bytes or params exceed 1 MiB".into())));
}
let root = self.table.get(&project)?.root.clone();
let token = self.cancel.token();
let timeout = self.io_timeout();
let outcome = self.host.lsp_request(&root, &server, &method, params, notify, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map_err(to_wit))
}
}
impl bindings::openidea::plugin::language_servers::Host for StoreData {
fn request(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
) -> Fallible<String> {
self.lsp_call(project, server, method, params, false)
}
fn notify(
&mut self,
project: Resource<ProjectRes>,
server: String,
method: String,
params: Option<String>,
) -> Fallible<()> {
Ok(self.lsp_call(project, server, method, params, true)?.map(|_| ()))
}
}
impl bindings::openidea::plugin::extensions::Host for StoreData {
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
Ok(self
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
75123cf60f87ec9bdd36e0b7f75efb69d000136d74c3e9da893ecd1534bdf534
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
60d66ec578e3adc7adaebbcd01aa92a74a8a7e4cfd4d1aa778f9b033c75234a6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
df55973ed77e734e48fe1d2b7bb5d1e4eb68153b17452e4385599145427292b6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
94f1c4a8192e15961bd136cf9dc3416fca7b295bce0d563608a3ee9fdf81c28e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
db140480eaf4352fa8db8d87aab95cb07944e19c28d2cc08e299b37c5deb4230
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
5fd76f6168d3a7b3259281171f375cf01da6ceabdf4ffd382c5fe7034b2e94f2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
5d9049fa006194e92e06de47ab933d249118706a8ba92100493318a1582f2b65
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
c723d915b69292740a863b3a94db6f6f8c218ecda46c527229d621084f2df3b1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
2dbab98cfc473650675b2691c7d0f2d5b088ffd6208e31ef30aaa54dd3b1b4b0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
1fa1515aae810e588f2c1d25728755c25fd51c78d35d96d823583052372c34dd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
69eaf24b0ed3260a6b3656563b1d0b8b268de81899f05bc984c668b0765e361f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
c6bd0a45a6cc3b09114be55c8b63563bea4ca6b748ee27c7843bb4ab05d7ce26
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
f870210add7c920df42e276a4cefcd1eaf01b3869f238c0a366beeb052e4adc3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
3a3c0d4e5a1564eaa2ca35b698cd4b553722476cf68995495b9f277631b08266
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
aab9d674d5d94b7488fe088e5c355511403edf7878c6fe124e2a2be86598ead6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
391ce453aa40de84ca4a6ed5f5b8ee88f5d659e8699029fa3a8940dac7d6dc9b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
ccbb5abb90a0bb49c0f51d0e2865d1c4092e28e9168e4c57e5f7b6ea971721ac
+336
Переглянути файл
@@ -0,0 +1,336 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "openidea-test-lsp-requests"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
Переглянути файл
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-lsp-requests"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
4788f82b35966e155dbf51af520acb2d5e7773ddf9f36f4239b9bd51d51b0fb3
+24
Переглянути файл
@@ -0,0 +1,24 @@
id = "test.lsp-requests"
name = "Language server requests fixture"
version = "0.1.0"
engines.openidea = "^1.9"
wasm = "dist/plugin.wasm"
[capabilities]
optional = ["lsp:mock"]
[[contributes.commands]]
id = "request"
title = "Ask the mock server for its routes"
[[contributes.commands]]
id = "notify"
title = "Tell the mock server something changed"
[[contributes.commands]]
id = "standard"
title = "Send a method of LSP itself"
[[contributes.commands]]
id = "ungranted"
title = "Ask a server the manifest does not name"
+19
Переглянути файл
@@ -0,0 +1,19 @@
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
#[derive(Default)]
struct LspRequests;
impl Plugin for LspRequests {
fn run_command(&mut self, id: &str, inv: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let project = inv.project().ok_or("no project")?;
match id {
"request" => project.language_server_request("mock", "mock/routes", arg).map(Some),
"notify" => project.language_server_notify("mock", "mock/changed", arg).map(|()| None),
"standard" => project.language_server_request("mock", "textDocument/hover", arg).map(Some),
"ungranted" => project.language_server_request("other", "other/status", arg).map(Some),
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(LspRequests);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
6e1d2ff774cc8088b4b8a6766914a7f752d05657634e6934503cebd2aa241fa2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
a11bc7b7151a62e98d665a6459b3c84e4cb836b734fd23e7bad3adc533655f81
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
aad4871073c7c7f22ab84f1e7965d1a861b7e5ddd9f86408e95eba4c3c021411
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
f2e0df07f6d7a501e96caf43efcfdaa0596aa2b9dad5c46ef71f5ad2eddc505b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
253f93d1254310669150f6fd629ae88223d7d6f1cd642803ec57382e5acd4451
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
9351d3248252ca35a40ce557ada29e8aa4f3135273e988235bc22ebcfd0505ec
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
7f4ad5bc15d70de0ae7a0e96c584edcd6caef1cb18b5450685a83d8a8de16d24
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6e045ee3fcaa655bae773774e48e3eeb9d97ae9d8db1f52bf76c38459dcecb3f
7d18591aa915fd6dd71fd8f8a4a0c7d8abb2cc5ff9252a56fb0d6b7458562466
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
69bb92e6e24e54458978a316c465d17b93d9ce9613cd26d7d6de9a1cef9b9447
55e59a27457e8f25e9bd0e727ebbc6276b0f45d210732a88ede4954ec2dc777f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc64a30e9d3b46275a6480641454b367b5082aea82da23a11b57106aa02387e1
8ce7d4a12f8bd393b8b32e695d3dd2994c30ba27fb8419b8af900a550cb2da7c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e6f3b5a4d81e62240fa1de4d1624b3706f145d47c014a1a2ed2b899cab24e0e4
f654b73781d88c25dddddcdb25e8c02195b36dc9e008dd9eafafd632a11c48c5
+1
Переглянути файл
@@ -1306,6 +1306,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
}),
connect: None,
fallback: false,
replaces: Vec::new(),
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
+82
Переглянути файл
@@ -25,6 +25,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
SdkKind::Jdk => jdk(),
SdkKind::Maven => maven(),
SdkKind::Ruby => crate::ruby::detect(root),
SdkKind::Zig => zig(),
};
if let Some(root) = root {
pinned_first(&mut out, kind, root);
@@ -40,6 +41,7 @@ fn tool_of(kind: SdkKind) -> Option<Tool> {
SdkKind::Jdk => Some(Tool::Java),
SdkKind::Maven => Some(Tool::Maven),
SdkKind::Ruby => Some(Tool::Ruby),
SdkKind::Zig => Some(Tool::Zig),
SdkKind::Php | SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None,
}
}
@@ -739,6 +741,86 @@ fn go() -> Vec<Sdk> {
})
}
// ------------------------------------------------------------------ zig
/// The directory holding the `zig` executable of the install rooted at `base`,
/// the way the common distributions lay it out: `zig` at the root (official
/// tarballs, mise / asdf installs), `<base>/bin/zig` (Homebrew) or one
/// directory down (zvm's nested version folders).
fn zig_dir(base: &Path) -> Option<PathBuf> {
if base.join(exe("zig")).is_file() {
return Some(base.to_path_buf());
}
let bin = base.join(bin_dir());
if bin.join(exe("zig")).is_file() {
return Some(bin);
}
subdirs(base).into_iter().find(|d| d.join(exe("zig")).is_file())
}
/// Zig toolchains: PATH, Homebrew, zvm and mise / asdf installs. `path` is the
/// directory holding `zig`, which is what `languages.zig.zig` stores.
fn zig() -> Vec<Sdk> {
let mut roots: Vec<(PathBuf, &'static str)> = Vec::new();
if let Some(bin) = which("zig").filter(|p| !version_managers::is_shim(p))
&& let Some(dir) = bin.parent()
{
roots.push((dir.to_path_buf(), "path"));
}
for p in ["/opt/homebrew/opt/zig", "/usr/local/opt/zig"] {
roots.push((PathBuf::from(p), "system"));
}
if let Some(home) = home_dir() {
for d in subdirs(&home.join(".zvm")) {
roots.push((d, "zvm"));
}
}
for (d, manager) in version_managers::installs(Tool::Zig) {
roots.push((d, manager));
}
let mut dirs: Vec<(PathBuf, &'static str)> =
roots.into_iter().filter_map(|(base, source)| zig_dir(&base).map(|dir| (dir, source))).collect();
let mut seen = HashSet::new();
dirs.retain(|(dir, _)| seen.insert(dir.canonicalize().unwrap_or_else(|_| dir.clone())));
par_map(&dirs, |(dir, source)| {
let version = query(&dir.join(exe("zig")), &["version"], VERSION_TIMEOUT).map(|v| v.trim().to_owned());
Some(Sdk {
path: dir.clone().into(),
name: format!("Zig {}", version.as_deref().unwrap_or_default()).trim().to_owned(),
version,
source: (*source).to_owned(),
home: Some(dir.clone().into()),
extra: None,
})
})
}
#[cfg(test)]
mod zig_tests {
use super::*;
#[test]
fn finds_the_zig_directory_of_each_layout() {
let tmp = tempfile::tempdir().unwrap();
let install = |rel: &str| {
let dir = tmp.path().join(rel);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(exe("zig")), "").unwrap();
dir
};
// Official tarballs and mise / asdf installs: `zig` at the root.
let root = install("0.14.1");
assert_eq!(zig_dir(&root), Some(root));
// Homebrew: <opt>/bin/zig.
let brew = install("brew/bin");
assert_eq!(zig_dir(brew.parent().unwrap()), Some(brew));
// zvm: one directory down.
let zvm = install("zvm/x");
assert_eq!(zig_dir(&tmp.path().join("zvm")), Some(zvm));
assert_eq!(zig_dir(&tmp.path().join("missing")), None);
}
}
// ------------------------------------------------------------------ typescript
/// `node_modules/typescript/lib` of the project (root or a direct workspace member).
+2
Переглянути файл
@@ -52,6 +52,8 @@ pub enum SdkKind {
/// Maven homes.
Maven,
Ruby,
/// Zig toolchain directories.
Zig,
}
/// One installed interpreter / toolchain.
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff. Show more