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

...
17 Коміти
Автор SHA1 Повідомлення Дата
ntbang0901 50f541fa64 fix(vcs): centre the diff viewer's revert controls (#2346)
The diff viewer's gutter arrows and the inline Revert button sat off-centre on their change blocks: they used fixed offsets that did not follow the editor's line height or the gutter width. They are now positioned from the editor's metrics and spacing tokens, centred independently of width. Covers modified, multi-line, deletion-only and insertion-only blocks, paired and merge accept controls, font sizes and zoom.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:24:23 +08:00
LzSkylineтаlzskyline 27401ad438 fix(markdown): stop inline Mermaid diagrams rendering black (#2322)
On a dark theme Mermaid diagrams painted near-black: node fills and borders came from the editor surface and chrome border, class-diagram labels used nodeBorder, and with darkMode Mermaid darkened every derived series colour by 75%, turning pies black. Fills now use a lifted surface, strokes and labels use the foreground, series colours are passed in explicitly, Gantt task labels stay readable against their bars, and diagrams use the classic look (the neo look hard-codes a black start state). Applies to the Markdown preview and agent chat, which share the theme helper; covered for flowchart, sequence, class, state, pie and Gantt in light and dark.

Co-authored-by: lzskyline <[email protected]>
2026-11-10 01:19:12 +08:00
Ilya Lтаmushketer888 3ae6bddcc0 feat(arduino): add sketch code intelligence (#2177)
.ino sketches get C++ highlighting and code intelligence through Arduino Language Server: completion, signature help, hover, definition navigation and diagnostics. The IDE finds arduino-cli, clangd and the language server installed by Arduino IDE 2 (or paths from languages.arduino settings), reads the board FQBN from sketch.yaml or settings, roots the server at the sketch, and works around the server's capability quirks. Build, upload and serial monitor are left to a plugin.

Co-authored-by: mushketer888 <[email protected]>
2026-11-10 01:16:21 +08:00
ntbang0901 b6cb416036 fix(workbench): keep empty editor hints aligned when an action is unbound (#2345)
The empty editor's hint grid places cells automatically in two columns; a row whose action had no shortcut left out its key cell, so every later label moved into the shortcut column. Rows with an action now always render the key cell, empty when unbound. Covered over every keymap preset and user overrides.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:58:36 +08:00
l0ng-ai 4126822be8 fix(window): preserve fullscreen when adding project tabs (#2389)
Adding a project to a native macOS tab group anchored the group on the newly opened window, so AppKit took that window's normal frame and the group left full screen (black margins, stacked title bar and tab strip). Tab sync now anchors on the group's currently selected window and only selects the new tab. Verified on macOS with the system tab preference set to always and manual, in full screen and windowed: open, switch, close, merge and detach tabs. Fixes #2224.
2026-11-10 00:53:59 +08:00
Qingтаqtvbwfn 29af5994e3 feat(syntax): support structure and local navigation in inline scripts (#1593)
Without a language server, inline <script> blocks of HTML containers (vue, html, svelte, jsp) had no File Structure entries, breadcrumbs or Go to Declaration. The structure service now nests each script block's TS/JS declarations under its script entry, with region-local UTF-16 positions mapped to document coordinates, and the declaration fallback answers from the block grammar's tree inside a script. Language server answers still take precedence. Covers multiple and uppercase script blocks, CRLF, multi-byte text before a script, memo reuse per document version and parser errors.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-10 00:43:33 +08:00
l0ng-ai 226ac26954 fix(run): resolve stack frames from all exact source names (#2313)
Stack-trace navigation only searched the first 50 fuzzy file matches, so in a project with many files of the same name the project source could be skipped in favour of a library or decompiled class. Frames now look up every indexed file with the exact source basename (new find_files_named) and then apply the existing package-path and declaration checks. Covers Java, Kotlin (including sources outside their package directory), Scala and Groovy; older remote servers fall back to their full fuzzy list. Refs #1660.
2026-11-10 00:39:59 +08:00
l0ng-ai 658c38a193 fix(app): keep manual update check answers visible (#2385)
A manual update check could end without a visible answer: balloons were hidden whenever a saved layout listed Notifications as open, even when that tool window was not actually shown (welcome screen, hidden panel), and marking the list read dropped unlogged balloons. Balloons now defer only to an effectively visible Notifications tool window, and only listed entries are marked read. Covers update available, up to date and error in project, welcome and light-edit windows. Fixes #2285.
2026-11-10 00:16:38 +08:00
ntbang0901 20be9ff646 fix(settings): stop a theme picked earlier staying previewed in the Settings window (#2343)
The theme row only re-previewed a staged theme that differed from the saved one, and only when the list closed, so re-picking the saved theme quickly or pressing Reset left the earlier pick painted in the Settings window. It now shows its staged theme, or the saved one, whenever that changes and whenever the list closes.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:03:00 +08:00
LU XIANGRUIтаLU XIANGRUI 394e9919c7 feat(project-view): reveal library classes opened from jars in External Libraries (#2378)
- Map jdt://, jar: and decompiled class URIs to <archive>!/<entry> paths inside the library archive
- revealPath handles them in one place, so Select Opened File and autoscroll both work

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:41:30 +08:00
l0ng-ai e839e5dfb7 fix(terminal): preserve protocol replies during IME commits (#2312)
The IME duplicate-commit gate dropped every emission after the first one in a commit, including xterm's device/cursor replies and mouse reports, so TUIs stopped getting answers after typing through an IME. The gate now only collapses text; ESC-prefixed output always reaches the PTY. Refs #2160.
2026-11-09 23:38:34 +08:00
LU XIANGRUIтаLU XIANGRUI ff17c3753f feat(plugins): notifications targeted at one project (API 1.5) (#2375)
- Add ui.notify-project: it shows only in that project's window (the most recently focused one if there are several) and works without a calling window
- Button commands run in the window showing the notification; a closed project returns not-found, with nothing shown and no fallback to another window
- Bump the plugin API to 1.5.0 (wit/, SDK copy, ENGINE_VERSION, API_VERSION) and rebuild all components
- Add Project::notify and its mock to the SDK; update plugins/README.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:20:38 +08:00
LU XIANGRUIтаLU XIANGRUI 5f970d97d5 feat(data): move rebuildable local data with OPENIDEA_LOCAL_DATA_DIR (#2373)
- The local data directory (tools, language servers, index caches) is resolved in one place, ide_host::data_dirs::local_data_dir
- The variable must be an absolute path; a missing directory is created, and an unwritable one exits with an error instead of falling back to the system default
- Config and log directories still follow the system
- Add docs/data-directories.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:01:11 +08:00
ntbang0901таNguyenBang 16bf9b53a2 feat(plugins): show a running state on a plugin command's toolbar button (#2371)
A plugin command that takes seconds (an LLM call that fills the commit message) gave no
sign at the button that the click was taken, and a second click started a second run.

Actions may now set `indicatesRunning`: the action runner counts their in-flight runs
and an IconButton bound to such an action shows a spinner, sets aria-busy and ignores
clicks until the run settles. Plugin commands set it, so every toolbar slot that draws
them (editor.title, mainToolbar, commit.message) is covered without a plugin API change
and without features importing each other. The button stays focusable (aria-disabled,
not disabled). Spinner moves to its own module so Button can use it without a cycle
with Feedback, which re-exports it.

Refs #2254

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 22:43:53 +08:00
LU XIANGRUIтаLU XIANGRUI 68dd88993b feat(database): submit and revert pending console result edits from the keyboard (#2370)
- The console result grid reuses the table editor's Submit / Revert actions and shortcuts, active only while the grid has focus and pending edits
- ⌘↩ while editing a cell confirms that cell before submitting (the table editor does the same)
- Submit / Revert button tooltips show their shortcuts; the result grid supports Go to Column
- ⌘↩ still executes the statement in the SQL editor

Closes #2332

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 22:39:29 +08:00
l0ng-ai b50219b6fa build: use thin LTO for release builds (#2366) 2026-11-09 22:04:59 +08:00
LU XIANGRUIтаLU XIANGRUI 7090a8db56 test(markdown): cover Mermaid flowchart nodes and sequence control blocks against solid black (#2363)
- Render the flowchart from the issue verbatim and assert its nodes and subgraph are not pure black
- Add an assertion for the sequence diagram loop control block (.labelBox); the #2288 test only covered cluster

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 21:54:41 +08:00
230 змінених файлів з 3395 додано та 351 видалено

No files matched your search

+3 -1
Переглянути файл
@@ -132,7 +132,9 @@ debug = false
[profile.release]
codegen-units = 1
lto = true
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+10
Переглянути файл
@@ -76,4 +76,14 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Arduino sketches (`.ino`) have C++ syntax highlighting and code intelligence via Arduino
Language Server. For completion, signature help, hover and definition navigation, install
Arduino IDE 2 and the board's core. Configure the server command and arguments in
Settings > Languages & Frameworks > Language Servers, or set tool paths in `settings.toml`
using `languages.arduino.cli`, `languages.arduino.clangd`, `languages.arduino.languageServer`
and `languages.arduino.cliConfig`. OpenIDEA
automatically discovers the IDE's Arduino CLI, clangd and Arduino Language Server on
Windows, macOS and Linux; board FQBN is read from `sketch.yaml` or
`languages.arduino.fqbn` in `settings.toml`.
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+13
Переглянути файл
@@ -656,6 +656,7 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
@@ -677,10 +678,22 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
+1 -1
Переглянути файл
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.4.0";
pub const ENGINE_VERSION: &str = "1.5.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+79
Переглянути файл
@@ -1,9 +1,39 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
let probe = dir.join(".openidea-write-check");
fs::create_dir_all(&dir)
.and_then(|()| fs::write(&probe, b""))
.and_then(|()| fs::remove_file(&probe))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -276,6 +306,55 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+32
Переглянути файл
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -46,8 +48,16 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -68,6 +78,7 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -122,3 +133,24 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+6
Переглянути файл
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+40 -6
Переглянути файл
@@ -110,6 +110,10 @@ pub trait PluginWorkspace: Send + Sync {
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Windows showing the open project at `root`, the most recently focused first.
fn project_windows(&self, _root: &Path) -> Vec<String> {
Vec::new()
}
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
@@ -1758,17 +1762,31 @@ impl HostApi for PluginApi {
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
if !actions.is_empty() {
let commands = self.shared.ui.items("commands", Some(self.id()));
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
}
}
check_actions(self, &actions)?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
check_actions(self, &actions)?;
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
let window = windows
.into_iter()
.find(|w| !self.shared.client_closed(w))
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
@@ -2718,6 +2736,15 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
}
}
/// Notification buttons must name commands the plugin declares.
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
None => Ok(()),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
@@ -3016,6 +3043,13 @@ impl PluginWorkspace for HostWorkspace {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn project_windows(&self, root: &Path) -> Vec<String> {
let Some(host) = self.0.upgrade() else { return Vec::new() };
let mut windows = host.windows_for_root(root);
host.mcp_reports.most_recently_focused_first(&mut windows);
windows
}
fn publish_diagnostics(
&self,
source: &str,
+1
Переглянути файл
@@ -290,6 +290,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_files_named"
| "find_dirs"
| "find_symbols"
| "mark_recent"
+38
Переглянути файл
@@ -126,6 +126,10 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
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() }
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
@@ -4801,6 +4805,40 @@ fn notification_actions_keep_commands_and_window_scope() {
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn project_notifications_go_to_a_window_of_that_project_only() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
f.host.forget_client("closing");
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
f.workspace.other_roots.lock().push(other.clone());
f.take_events();
let notifications = |f: &Fixture| {
f.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
_ => None,
})
.collect::<Vec<_>>()
};
// A scheduled call has no invoking window; a closing window is skipped.
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
let sent = notifications(&f);
assert_eq!(sent.len(), 1);
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
// A project without a window fails without falling back to another one, even when invoked from one.
let path = other.join("notes.txt").to_string_lossy().into_owned();
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
assert!(error.contains("not open"), "{error}");
assert!(notifications(&f).is_empty());
assert_eq!(f.status("jobs"), PluginStatus::Active);
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
+9
Переглянути файл
@@ -234,6 +234,15 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
let data = self.shared.data.read();
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
files.into_iter().map(|file| file.path.clone()).collect()
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
+21
Переглянути файл
@@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
}
#[test]
fn exact_file_names_keep_all_source_candidates() {
let f = Fixture::new(&[
("src/OtherOrders.kt", "package other"),
("src/Orders.kt.bak", "backup"),
("target/Orders.kt", "excluded"),
("src/orders.kt", "different case"),
]);
for i in 0..65 {
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
}
let source = f.write("service/src/Orders.kt", "package com.example.orders");
let idx = f.open();
let candidates = idx.files_named("Orders.kt");
assert_eq!(candidates.len(), 66);
assert!(candidates.contains(&source));
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
assert_eq!(candidates, idx.files_named("Orders.kt"));
assert!(idx.files_named("Missing.kt").is_empty());
}
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
}
+14 -2
Переглянути файл
@@ -14,14 +14,14 @@ mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::marked_root;
pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
WebReferenceKind, merge_visibility, set_external_syntax,
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -332,3 +332,15 @@ pub struct SymbolTag {
pub container: Option<String>,
pub range: Range,
}
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptRegion {
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
/// Convert to UTF-16 positions before exposing ranges through IPC.
pub base: usize,
/// The language the block's `lang` attribute picks.
pub language: LanguageId,
/// The script text.
pub text: String,
}
+21
Переглянути файл
@@ -19,6 +19,27 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains at least one `.ino` regular file.
pub fn is_arduino_sketch_dir(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino"))
&& entry.file_type().is_ok_and(|kind| kind.is_file())
})
})
}
/// Nearest enclosing directory of `path` (including `path` when it is already a
/// directory) within `project` that contains a `.ino` file.
pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
let start = usize::from(!path.is_dir());
path.ancestors()
.skip(start)
.take_while(|dir| dir.starts_with(project))
.find(|dir| is_arduino_sketch_dir(dir))
.map(Path::to_path_buf)
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
+43 -3
Переглянути файл
@@ -195,8 +195,16 @@ impl SyntaxCache {
|| {
self.with_tree(doc, lang, version, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
// HTML documents answer from the cached tree, with nested
// <script> declarations.
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
})
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
// own, hence no cached tree: the syntax layer answers with a fresh
// HTML parse, which the memo covers per version.
// shortcut: no incremental re-parse for containers, upgrade when
// their trees join the cache.
.or_else(|| self.syntax.web_outline(lang, text))
.or_else(|| super::external::outline(lang, text))
},
)
@@ -256,8 +264,11 @@ impl SyntaxCache {
let Some(result) = compute() else { return Vec::new() };
if let Some(e) = &entry {
let mut e = e.lock();
// Not if the document moved on (or its tree was dropped) meanwhile.
if e.version == version && e.tree.is_some() {
// Containers without their own grammar memoize their web outline too.
// Never store an answer after the document moves on.
if e.version == version
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
{
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
}
}
@@ -474,6 +485,35 @@ mod tests {
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
}
#[test]
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
for language in ["html", "vue", "svelte", "jsp"] {
let cache = cache();
let id = DocId::next();
let lang = LanguageId::new(language);
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
cache.open(id, &lang, 0);
let first = cache.outline(id, &lang, 0, &doc.text());
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first);
assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit");
let edits = edit(&mut doc, 0, 0, "<script>const second = 2;</script>\n");
cache.edit(id, doc.version(), &edits);
let second = cache.outline(id, &lang, doc.version(), &doc.text());
assert_ne!(second, first);
assert_eq!(second, cache.syntax().outline(&lang, &doc.text()));
// An older query must not replace the current version's memo.
cache.outline(id, &lang, 0, "<script>const old = 0;</script>");
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second);
assert_eq!(cache.stats().hits, hits + 1);
cache.clear();
assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none());
cache.close(id);
}
}
#[test]
fn version_mismatch_and_lifecycle() {
let cache = cache();
+25
Переглянути файл
@@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// Shifts a position from a document suffix that starts at `base` into document
/// coordinates: the suffix's first line continues `base`'s line, later lines
/// add the base line. Used to map inline `<script>` region answers (Vue SFC /
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
}
/// [`shift_pos`] for both ends of a range.
pub fn shift_range(range: Range, base: Pos) -> Range {
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
@@ -160,6 +173,18 @@ mod tests {
}
}
#[test]
fn shifts_suffix_positions_into_document_coordinates() {
let base = Pos::new(3, 7);
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
assert_eq!(
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
Range::new(Pos::new(3, 8), Pos::new(4, 4))
);
}
#[test]
fn long_minified_line_is_linear() {
// 200k symbols-worth of positions on one 4 MB line.
+43 -1
Переглянути файл
@@ -10,15 +10,17 @@ pub(crate) mod lines;
mod literals;
mod outline;
mod selection;
mod sfc;
mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
use std::cell::RefCell;
@@ -165,6 +167,12 @@ impl Syntax {
}
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
// HTML itself parses alike): HTML outline with each inline `<script>`'s
// TS/JS declarations nested under its entry, in document coordinates.
if let Some(items) = self.web_outline(lang, text) {
return items;
}
self.analyze(lang, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
@@ -172,6 +180,20 @@ impl Syntax {
.unwrap_or_default()
}
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
/// for every other language, CSS containers included.
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
let g = self.web_grammar(lang)?;
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
return None;
}
let tree = parse(&g.language, text, None)?;
let query = g.outline_query()?;
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
self.outline(region_lang, region_text)
}))
}
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
let tokens = self.comment_tokens(lang);
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
@@ -248,6 +270,19 @@ impl Syntax {
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
}
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
/// document order. Empty for other languages or non-JavaScript blocks.
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
}
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
/// lends the tree and text) — the cache-aware way for other crates, which
/// reuses a cached incremental HTML tree instead of parsing again.
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
sfc::script_regions(tree, text)
}
/// Highlighting from the grammar's own query; `None` without one.
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
self.analyze(lang, text, |g, tree, text| {
@@ -280,6 +315,13 @@ impl Syntax {
self.grammar(lang).map(|g| g.spec.name)
}
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
/// grammar or no web features. Lets other crates recognize containers.
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
self.web_grammar(lang).map(|g| g.spec.name)
}
/// Grammar name and tree-sitter language for `lang`, for engines that parse
/// on their own (structural search patterns).
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
+65 -2
Переглянути файл
@@ -13,9 +13,9 @@
//! macro event operator key heading impl extension.
use super::Grammar;
use super::lines::LineIndex;
use super::lines::{LineIndex, shift_range};
use crate::SymbolTag;
use ide_core::{OutlineItem, SymbolKind, Visibility};
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
use std::collections::HashMap;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
@@ -296,6 +296,69 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
collect(g, q, tree, text).into_iter().map(convert).collect()
}
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
/// TS/JS declarations nested under its `script` entry and shifted to document
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
/// language, answering in region-local coordinates.
pub(crate) fn outline_nested(
g: &Grammar,
q: &OutlineQuery,
tree: &Tree,
text: &str,
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
) -> Vec<OutlineItem> {
let mut items = outline(g, q, tree, text);
if g.spec.name != "html" {
return items;
}
let lines = LineIndex::new(text);
for region in super::sfc::script_regions(tree, text) {
let mut decls = block(&region.language, &region.text);
if decls.is_empty() {
continue;
}
let base = lines.pos(region.base);
shift_outline(&mut decls, base);
if !attach_script(&mut items, base, &mut Some(decls)) {
// The region and the entry come from the same `script_element` node,
// so this means the outline query no longer matches script elements.
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
}
}
items
}
/// Recursively shifts region-local positions in `items` to document coordinates.
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
for item in items {
item.range = shift_range(item.range, base);
item.selection_range = shift_range(item.selection_range, base);
shift_outline(&mut item.children, base);
}
}
/// Nests `decls` under the innermost `script` entry covering `base`. The
/// region and the entry both come from the same `script_element` node, so the
/// entry always exists; a miss means the outline query no longer matches it.
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
let covers = |range: &ide_core::Range| {
(range.start.line, range.start.col) <= (base.line, base.col)
&& (base.line, base.col) < (range.end.line, range.end.col)
};
for item in items.iter_mut() {
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
item.children.extend(decls.take().unwrap_or_default());
return true;
}
}
for item in items.iter_mut() {
if attach_script(&mut item.children, base, decls) {
return true;
}
}
false
}
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
for b in items {
+92
Переглянути файл
@@ -0,0 +1,92 @@
//! Inline TS/JS `<script>` regions located with the HTML grammar's
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
//!
//! Blocks whose `type` attribute names anything but JavaScript are skipped
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
//! `lang` values.
use super::web::walk;
use crate::ScriptRegion;
use ide_core::LanguageId;
use tree_sitter::{Node, Tree};
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
fn lang_grammar(value: &str) -> Option<&'static str> {
match value {
"ts" | "typescript" => Some("typescript"),
"tsx" => Some("tsx"),
"" | "js" | "jsx" | "javascript" => Some("javascript"),
_ => None,
}
}
/// `true` when a `type` attribute value names JavaScript (or is absent).
fn is_js_type(value: &str) -> bool {
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
}
/// The `<script>` regions of a document parsed with the HTML grammar,
/// in document order. `base` is each block's byte offset into the document.
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() != "script_element" {
return true;
}
let mut lang = "javascript";
let mut skip = false;
let mut c = n.walk();
for ch in n.children(&mut c) {
match ch.kind() {
// The start tag precedes the raw text, so the attributes are
// known before the region is taken.
"start_tag" => {
let mut c2 = ch.walk();
for attr in ch.children(&mut c2) {
if attr.kind() != "attribute" {
continue;
}
let (name, value) = attribute(attr, text);
match name.to_ascii_lowercase().as_str() {
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
Some(g) => lang = g,
None => skip = true,
},
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
"src" => skip = true,
_ => {}
}
}
}
"raw_text" if !skip => {
out.push(ScriptRegion {
base: ch.start_byte(),
language: LanguageId::new(lang),
text: text[ch.start_byte()..ch.end_byte()].to_string(),
});
}
_ => {}
}
}
false // the raw text is not markup
});
out
}
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
let mut c = attr.walk();
let mut name = "";
let mut value = "";
for ch in attr.children(&mut c) {
match ch.kind() {
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
"quoted_attribute_value" => {
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
}
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
_ => {}
}
}
(name, value)
}
+229
Переглянути файл
@@ -759,6 +759,7 @@ fn outline_html_css_xml() {
Field body
Field div
Field script
Variable a
",
);
check_outline(
@@ -1409,3 +1410,231 @@ fn spelling_regions_cover_declared_names_only() {
.collect();
assert_eq!(names, ["Bird", "fly", "text"]);
}
// ---------------------------------------------------------------------------
// Script regions of Vue SFCs and HTML documents
#[test]
fn script_regions_skip_typed_blocks() {
let src = "<html><body><script type=\"module\">var x = 1;</script>\
<script type=\"application/json\">{\"a\":1}</script>\
<script type=\"importmap\">{}</script>\
<script>var y = 2;</script></body></html>";
let regions = syntax().script_regions(&lang("html"), src);
assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped");
assert_eq!(regions[0].language.as_str(), "javascript");
}
#[test]
fn script_regions_pick_the_lang_attribute() {
let src = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\n";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].language.as_str(), "tsx");
assert!(regions[0].text.contains("const App"));
}
#[test]
fn script_regions_empty_for_other_languages() {
assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty());
assert!(syntax().script_regions(&lang("vue"), "<template><div/></template>").is_empty());
}
#[test]
fn script_regions_classify_attributes() {
for language in ["vue", "html"] {
for (attributes, expected) in [
("", Some("javascript")),
("setup lang=ts", Some("typescript")),
("lang='typescript'", Some("typescript")),
("lang=tsx", Some("tsx")),
("lang=js", Some("javascript")),
("lang=jsx", Some("javascript")),
("LANG=TS", Some("typescript")),
("type=module", Some("javascript")),
("TYPE=' text/JAVASCRIPT '", Some("javascript")),
("type=application/javascript", Some("javascript")),
("type=application/json", None),
("type='application/ld+json'", None),
("type=text/template", None),
("type=importmap", None),
("type=speculationrules", None),
("lang=coffee", None),
("lang=ts type=application/json", None),
("src='./external.ts' lang=ts", None),
("SRC=external.js", None),
("src", None),
] {
let src = format!("<script {attributes}>const value = 1;</script>");
let regions = syntax().script_regions(&lang(language), &src);
assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}");
if let Some(expected) = expected {
assert_eq!(regions[0].language.as_str(), expected, "{attributes}");
assert_eq!(regions[0].text, "const value = 1;");
assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text);
}
}
}
}
#[test]
fn script_regions_preserve_document_offsets_and_line_endings() {
use super::lines::LineIndex;
let src = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 2);
let idx = LineIndex::new(src);
let doc = ide_text::Document::new(src);
for (region, (body, pos)) in
regions.iter().zip([("const 名 = '😀';\r\n", Pos::new(1, 16)), ("const View = () => <div/>;", Pos::new(3, 23))])
{
assert_eq!(region.base, src.find(body).unwrap());
assert_eq!(region.text, body);
assert_eq!(idx.pos(region.base), pos);
assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len()));
assert_eq!(
idx.pos(region.base + body.len()),
doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len())
);
if let Some(name) = body.find('名') {
assert_eq!(
idx.range(region.base + name, region.base + name + '名'.len_utf8()),
Range::new(Pos::new(1, 22), Pos::new(1, 23))
);
}
}
let same_line = "<template>中文😀</template><script>let x = 1;</script>";
let region = &syntax().script_regions(&lang("html"), same_line)[0];
assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33));
}
#[test]
fn script_regions_tolerate_incomplete_markup() {
for src in [
"<script",
"<script lang=",
"<script lang='ts",
"<script>const unfinished =",
"<template><div><script lang=ts>const x = 1;</script>",
"<script></script>",
"<script src='missing.js'>ignored</script><script>const x = 1;",
] {
for region in syntax().script_regions(&lang("vue"), src) {
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
}
}
}
#[test]
fn script_regions_large_vue_document() {
for count in [2_000, 4_000, 8_000] {
let src =
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\r\n".repeat(count);
let start = std::time::Instant::now();
let regions = syntax().script_regions(&lang("vue"), &src);
eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed());
assert_eq!(regions.len(), count);
assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base));
}
}
#[test]
fn script_regions_tolerate_deep_markup() {
let src = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
let regions = syntax().script_regions(&lang("html"), &src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].base, src.find("const").unwrap());
}
// ---------------------------------------------------------------------------
// Outline with nested <script> declarations (Vue SFC / HTML)
#[test]
fn html_outline_nests_script_declarations() {
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
check_outline(
"html",
src,
"
Field html
Field body
Field script
Function boot
Constant app
Field script
",
);
}
#[test]
fn vue_outline_nests_script_declarations_without_a_grammar() {
let src = "<template>\n <div class=\"app\"/>\n</template>\n\
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
<style scoped>\n.app { color: red }\n</style>";
check_outline(
"vue",
src,
"
Field template
Field script
Interface Props
Property size
Function scale
Field style
",
);
}
#[test]
fn script_outline_positions_are_document_coordinates() {
use super::lines::LineIndex;
let src = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
let outline = syntax().outline(&lang("vue"), src);
let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0];
// Cross-check against the document's own byte -> UTF-16 conversion.
let idx = LineIndex::new(src);
let at = src.find("answer").unwrap();
assert_eq!(decl.selection_range, idx.range(at, at + "answer".len()));
// `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword.
assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2));
}
#[test]
fn cached_outline_matches_syntax_outline_for_web_documents() {
use ide_core::DocId;
use std::sync::Arc;
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
let id = DocId::next();
let src = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\n";
cache.open(id, &lang("vue"), 0);
assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src));
cache.open(id, &lang("html"), 0);
assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src));
}
#[test]
fn script_outlines_compose_positions_across_multiple_blocks() {
use super::lines::LineIndex;
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
let lines = LineIndex::new(src);
for id in ["html", "vue", "svelte", "jsp"] {
let items = syntax().outline(&lang(id), src);
let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect();
assert_eq!(scripts.len(), 2, "{id}");
assert_eq!(scripts[0].children.len(), 1);
assert_eq!(scripts[1].children.len(), 2);
for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) {
let byte = src.find(name).unwrap();
assert_eq!(item.name, name);
assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}");
}
}
}
#[test]
fn script_outline_attaches_to_case_insensitive_html_tags() {
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
}
+14 -7
Переглянути файл
@@ -81,13 +81,20 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t
}
/// Pre-order walk; `f` returns whether to descend.
fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
if !f(n) {
return;
}
let mut c = n.walk();
for child in n.children(&mut c) {
walk(child, f);
pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
let mut cursor = n.walk();
loop {
if f(cursor.node()) && cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
+210
Переглянути файл
@@ -0,0 +1,210 @@
//! Arduino sketches need their own board context, including their C/C++ sources.
use crate::LspManager;
use crate::server::Server;
use ide_core::{LanguageId, Location};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const SERVER: &str = "arduino-language-server";
pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option<PathBuf> {
ide_language::arduino_sketch_root(project, file)
}
pub(crate) fn configure_client_capabilities(capabilities: &mut Value) {
// Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225),
// and cannot handle clangd's semanticTokens refresh request (#125).
for section in ["textDocument", "workspace"] {
if let Some(section) = capabilities[section].as_object_mut() {
section.remove("semanticTokens");
section.remove("inlayHint");
}
}
// ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`.
// Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised,
// causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit".
if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion"))
&& let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut())
{
item.remove("insertReplaceSupport");
}
}
pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) {
// Arduino Language Server's request dispatcher advertises documentSymbol and
// workspaceSymbol, but panics with "not implemented" / "unimplemented" when called.
// Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS.
if let Some(obj) = capabilities.as_object_mut() {
obj.remove("documentSymbolProvider");
obj.remove("workspaceSymbolProvider");
}
}
impl LspManager {
pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option<Vec<Arc<Server>>> {
if !matches!(language.as_str(), "c" | "cpp") {
return None;
}
let configs = self.language_servers(&LanguageId::new("arduino"));
let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?;
let project = self.project_root_for(path);
if sketch_root(&project, path).is_some() {
let server = self.server_for(cfg, &LanguageId::new("arduino"), path);
server.add_language(language);
let mut servers = vec![server];
servers.extend(
self.language_servers(language)
.iter()
.filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER)
.filter(|cfg| {
!cfg.require_root_marker
|| crate::launch::marked_root(&project, path, &cfg.root_markers).is_some()
})
.map(|cfg| self.server_for(cfg, language, path)),
);
return Some(servers);
}
if !path.starts_with(&project)
&& let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path))
&& server.name == SERVER
{
return Some(vec![server]);
}
None
}
pub(crate) fn note_arduino_sources(&self, server: &Arc<Server>, locations: &[Location]) {
if server.name != SERVER {
return;
}
let mut origins = None;
for location in locations {
let path: &Path = &location.path;
if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) {
origins
.get_or_insert_with(|| self.virtual_origins.lock())
.insert(crate::uri::path_to_uri_string(path), server.id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_server_allows_diagnostics() {
let registry = ide_language::LanguageRegistry::load(None).unwrap();
let language = registry.get(&LanguageId::new("arduino")).unwrap();
let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap();
assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics());
}
#[tokio::test]
async fn inspection_checks_the_sketch_server_and_root_for_all_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let expected_root = sketch.clone();
manager.set_config_hook(Box::new(move |cfg, server_root| {
cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned();
if cfg.name != SERVER || server_root != expected_root {
cfg.launch_error = Some("unavailable".into());
}
}));
for name in ["blink.ino", "helper.cpp", "helper.h"] {
assert!(manager.serves_path(&sketch.join(name)), "{name}");
}
assert!(!manager.serves_path(&root.join("main.cpp")));
}
#[test]
fn nested_sketches_keep_separate_board_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let inner = root.join("examples/blink");
std::fs::create_dir_all(inner.join("src")).unwrap();
std::fs::write(root.join("main.ino"), "").unwrap();
std::fs::write(inner.join("blink.ino"), "").unwrap();
assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone()));
assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner));
assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf()));
assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None);
}
#[test]
fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap();
assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None);
}
#[tokio::test]
async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let cpp = LanguageId::new("cpp");
let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp"));
assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [SERVER]);
assert_eq!(sketch_servers[0].root, sketch);
assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino")));
assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd");
manager.set_servers_hook(Box::new(
|language, defined| {
if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() }
},
));
assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd");
}
#[test]
fn capabilities_are_sanitized_for_arduino_proxy_quirks() {
let mut client = serde_json::json!({
"textDocument": {
"semanticTokens": { "dynamicRegistration": false },
"inlayHint": { "dynamicRegistration": false },
"completion": {
"completionItem": {
"snippetSupport": true,
"insertReplaceSupport": true
}
}
},
"workspace": {
"semanticTokens": { "refreshSupport": true },
"inlayHint": { "refreshSupport": true }
}
});
configure_client_capabilities(&mut client);
assert!(client["textDocument"].get("semanticTokens").is_none());
assert!(client["textDocument"].get("inlayHint").is_none());
assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none());
assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
assert!(client["workspace"].get("semanticTokens").is_none());
assert!(client["workspace"].get("inlayHint").is_none());
let mut server = serde_json::json!({
"documentSymbolProvider": {},
"workspaceSymbolProvider": {},
"completionProvider": { "triggerCharacters": ["."] },
"hoverProvider": true
});
sanitize_server_capabilities(&mut server);
assert!(server.get("documentSymbolProvider").is_none());
assert!(server.get("workspaceSymbolProvider").is_none());
assert!(server.get("completionProvider").is_some());
assert!(server.get("hoverProvider").is_some());
}
}
+11
Переглянути файл
@@ -63,6 +63,17 @@ impl LspManager {
/// run doesn't prompt to install every language it meets.
pub fn serves_path(&self, path: &Path) -> bool {
let lang = self.languages.detect(path, None);
let arduino = if lang.as_str() == "arduino" {
Some(self.servers_for(&lang, path))
} else {
self.arduino_servers(&lang, path)
};
if let Some(servers) = arduino {
return servers.iter().any(|server| {
let cfg = self.configured(server);
cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root)
});
}
self.language_servers(&lang).iter().any(|ls| {
if ls.require_root_marker
&& crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none()
+19 -2
Переглянути файл
@@ -7,6 +7,7 @@
//! never `lsp-types`, so the IPC surface stays stable.
mod angular;
mod arduino;
mod caps;
mod clang_tidy;
mod convert;
@@ -502,10 +503,12 @@ impl LspManager {
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
let mut servers = self.servers.lock();
let project = self.project_root_for(path);
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
let root = if shared.as_ref().is_some_and(|state| state.shared) {
self.project_root.clone()
} else {
launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
@@ -530,6 +533,9 @@ impl LspManager {
/// only while all servers listed before it are missing. One that requires a
/// root marker serves only files below one, and stands in for no other.
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;
for sc in &self.language_servers(language) {
@@ -775,7 +781,11 @@ impl LspManager {
if let Some(info) = result.get("serverInfo") {
tracing::info!("{} initialized: {info}", server.name);
}
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
let mut caps_val = result.get("capabilities").cloned().unwrap_or_else(|| json!({}));
if server.name == arduino::SERVER {
arduino::sanitize_server_capabilities(&mut caps_val);
}
let mut caps = ServerCaps::parse(caps_val);
let cfg = server.effective_config();
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
caps
@@ -921,6 +931,9 @@ impl LspManager {
"capabilities": caps::client_capabilities(),
"trace": "off",
});
if server.name == arduino::SERVER {
arduino::configure_client_capabilities(&mut params["capabilities"]);
}
if server.name == "gopls" {
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
}
@@ -1560,6 +1573,7 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
@@ -1788,6 +1802,7 @@ impl LspManager {
.collect();
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locations);
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
}
.await;
@@ -1855,6 +1870,7 @@ impl LspManager {
locs.retain(|l| !declarations.contains(l));
}
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
acc.locations.extend(locs);
acc.incomplete = acc.incomplete.or(w.incomplete);
}
@@ -3312,6 +3328,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)
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER))
|| !configs
.iter()
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
+14 -1
Переглянути файл
@@ -54,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.4";
pub const API_VERSION: &str = "1.5";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -259,6 +259,19 @@ pub trait HostApi: Send + Sync {
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError>;
/// [`HostApi::notify`] in the most recently focused window of the open project at
/// `root`; not-found (and nothing shown) once it has closed.
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError> {
let _ = (root, level, title, body, actions);
Err(HostError::NotFound("project notifications unavailable".into()))
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
+41 -15
Переглянути файл
@@ -362,6 +362,25 @@ impl StoreData {
}
}
/// Checks `ui.notify` buttons; the host checks that their commands are declared.
fn notification_actions(actions: Vec<ui::NotificationAction>) -> Result<Vec<crate::NotificationAction>, wit::Error> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions")));
}
let mut checked = Vec::with_capacity(actions.len());
for a in actions {
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
return Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into()));
}
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
}
Ok(checked)
}
impl ui::Host for StoreData {
fn notify(
&mut self,
@@ -370,22 +389,29 @@ impl ui::Host for StoreData {
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
}
let mut checked = Vec::with_capacity(actions.len());
for a in actions {
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
}
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
}
let actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
Ok(self.host.notify(self.client(), notification_level(level), title, body, actions).map_err(to_wit))
}
fn notify_project(
&mut self,
target: Resource<ProjectRes>,
level: wit::NotificationLevel,
title: String,
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
let actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
let root = self.table.get(&target)?.root.clone();
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify_project(&root, notification_level(level), title, body, actions).map_err(to_wit))
}
fn show_notification(
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.4.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
let host = v("1.5.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
9963d6606bb6b859bebe1f55892488944f6d5e3308e6c0109f38f4b1a67b01f5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
7ab9937fdb2e78d0d62d9b85b78a0a6d9511224f85908c3a5a5e9c0d4b5e7e14
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
cf1f91d8accf8339bf6e22bef5067bfe3cba75d38cd30d8748b0aa451ee2495b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
12ad7d649d752f2a40b28dee718a3e04b64597496ab43787c084e3cb774ccfe6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
4bfce353b3c94490c5f95a046cd2371042b243c326bd63c65619e1489d7ab889
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
cfbd6aac217a7baea907affe4e1e7f89d1330843398e913e68b71f3a99cfb71b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
dcf00138820979f523bd93e1f6b08c627c6f061ae5b6e91b340646f905593cf3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
98e0e01e8405ae7501e89c0dab06e6672b5240a831b19e8fbf0604a3171b31e9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
fcee11f27ff9ae0038e8067712e269ccd49423372f692e9351c24f82906aafad
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
5c777d7b146277fa803587d1788a7359b08478def00c4574dd565d784200e39a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
825b9ee483796efd24fcfe358b94f4667d87e311475769ec9e41ba9af6d03a6a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
45b8cbd2ed7635ce04651937574dde9cf441dd619d76b8996f522c671950eaee
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
e456b7ddf087ffbf2df62431bf9df47fad1929079c99a941a5deaf30599109f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
e0d8b590ad6ffd9569c6362802b2be909cb34b37b18cc8cc8492f9c460610e20
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
d2512203db3e5c6df93e9ccd5ff8daed8f1ebd28462b8caf7d500e814d3b6671
+8
Переглянути файл
@@ -75,6 +75,14 @@ impl Plugin for Jobs {
)?;
Ok(None)
}
"notify-project" => {
// The argument is a path in the target project, as a scheduled check would have.
let path = arg.unwrap_or_default();
let project = host::project_of(path).ok_or("no project contains the path")?;
let action = openidea_plugin::NotificationAction::new("Disable", "context").argument(path);
project.notify(NotificationLevel::Info, "Project update", path, &[action])?;
Ok(None)
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
b32da5137b552cfad2557716343ed8ac9236b34fec018e3f2facc7b78b67e72a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
ae58467cdb6e98a491d739884eee96d239f80451de749950d81f4fd480edba58
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.4.0;
export openidea:plugin/providers@1.4.0;
export openidea:plugin/file-actions@1.4.0;
include openidea:plugin/plugin-syntax@1.5.0;
export openidea:plugin/providers@1.5.0;
export openidea:plugin/file-actions@1.5.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
e378c85a4fe395c7b172065a33a2f15ad76852ac42d4099d2cf1f64540e13143
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d695409029a66c8a44f524072be1d47ed974fc8810f6fe1d8d068765184d4028
23dc9ab1affb5b0c0166a5bb2bffb8a273a5c9eed0c06d402f16a1440b835639
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d5e81a43dc2c5298d0aaafd70c0bd91ab6cc040d70224d2936349de41253735f
d82fa182c8d5a029451addbf3b9792419446849911949e61052fca181d7758ce
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c29076023ccedf2da8fd9e5a851a47bbe7131b97713c8140a20cd55608a1cc0d
12337d77078aeb678567c0dc87c746a78454656ed52a2e51dd0bb81f19d90da3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
616157bd77dc272f9e2dfed4c54c9d65fca110e55ad01f92b7c4f8a6136537b4
9048b66162a39a518d117cd473f6192827bc05737ff5132324c54eb8295b060b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
022f216f0f4ca5da56f4b2fabf5ee4a987d5e9f9a752feecbb438f9b00b1acf4
274f7e74f19aabf9368c2b9a107494d82a2013d4adda645bde6d9ccda4007769
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
626f0e0c83e69704c0365c273b6f63d43278ee37786f38ed467ce908c30eadf7
e9082dc0f4c9c4fc682e16821d36f7fa42be9c82b75ac28edbee6a869dc86b8b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aa141701049f5c6c0d045341c3337344ed7a368800f08237b1b7812cce216814
0028a0439e8bb234fb0cc5138e1c27d5c2c8443bed12963a69f0bd8b3818ec7b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d6c40099cc959ef2dead996beeb4acd73c1327597cd029674026c6f2bc878300
f3b633e9e34a5343f318e4e451ffe1c87a6d1ac6d0dc01a57588ea99380dd8d4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3201f643cf868f7a358ba812f9c90e06b982d864ed141b6b4c3fdeee13cb1559
02fba523571a945b16055e83ee72400f69ecb64238556e933cfd5c2c92c8ea10
+3
Переглянути файл
@@ -1047,6 +1047,9 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
if name == angular::SERVER {
configure_angular(cfg, root, server_root, s);
}
if name == crate::arduino::SERVER {
crate::arduino::configure(cfg, root, server_root, s);
}
}
/// The servers `language` uses after Settings › Language Servers: the
+344
Переглянути файл
@@ -0,0 +1,344 @@
//! Arduino Language Server tool discovery and launch configuration.
//! Resolves `arduino-language-server`, `arduino-cli` and `clangd` from PATH or Arduino IDE 2.
use crate::apply::setting_path;
use crate::exec::which;
use ide_core::settings::{ArduinoSettings, Settings};
use ide_core::{IdeError, IdeResult};
use ide_language::LanguageServerConfig;
use std::path::{Path, PathBuf};
pub const SERVER: &str = "arduino-language-server";
fn user_home() -> Option<PathBuf> {
if cfg!(windows) {
ide_core::process::host_var_os("USERPROFILE").map(PathBuf::from).or_else(crate::home_dir)
} else {
crate::home_dir()
}
}
fn ide_resource_dirs() -> Vec<PathBuf> {
let mut installs = Vec::new();
if cfg!(windows) {
if let Some(local) = ide_core::process::host_var_os("LOCALAPPDATA") {
installs.push(PathBuf::from(local).join("Programs/Arduino IDE"));
}
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
if let Some(dir) = ide_core::process::host_var_os(var) {
installs.push(PathBuf::from(dir).join("Arduino IDE"));
}
}
installs.into_iter().map(|p| p.join("resources/app/lib/backend/resources")).collect()
} else if cfg!(target_os = "macos") {
installs.push(PathBuf::from("/Applications/Arduino IDE.app"));
if let Some(home) = user_home() {
installs.push(home.join("Applications/Arduino IDE.app"));
}
installs.into_iter().map(|p| p.join("Contents/Resources/app/lib/backend/resources")).collect()
} else {
vec![PathBuf::from("/opt/arduino-ide/resources/app/lib/backend/resources")]
}
}
fn resolve_tool(root: &Path, configured: &str, name: &str) -> IdeResult<PathBuf> {
let configured = configured.trim();
if !configured.is_empty() {
let path = setting_path(root, configured).unwrap_or_default();
if path.is_file() {
return Ok(path);
}
if Path::new(configured).components().count() == 1
&& let Some(path) = which(configured)
{
return Ok(path);
}
return Err(IdeError::unavailable(format!(
"Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino."
)));
}
which(name)
.or_else(|| ide_resource_dirs().into_iter().map(|p| p.join(crate::exe(name))).find(|p| p.is_file()))
.ok_or_else(|| IdeError::unavailable(format!(
"Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino."
)))
}
fn cli_config(root: &Path, configured: &str) -> IdeResult<Option<PathBuf>> {
if let Some(path) = setting_path(root, configured) {
if !path.is_file() {
return Err(IdeError::unavailable(format!("Arduino CLI configuration {} does not exist.", path.display())));
}
return Ok(Some(path));
}
Ok(user_home().map(|p| p.join(".arduinoIDE/arduino-cli.yaml")).filter(|p| p.is_file()))
}
fn yaml_scalar(value: &str) -> &str {
let value = value.trim();
let mut chars = value.char_indices();
if let Some((_, quote @ ('\'' | '"'))) = chars.next() {
while let Some((index, c)) = chars.next() {
if quote == '"' && c == '\\' {
chars.next();
} else if c == quote {
if quote == '\'' && chars.as_str().starts_with('\'') {
chars.next();
} else {
return &value[1..index];
}
}
}
return &value[1..];
}
let mut whitespace = true;
for (index, c) in value.char_indices() {
if c == '#' && whitespace {
return value[..index].trim_end();
}
whitespace = c.is_whitespace();
}
value
}
/// Parses `sketch.yaml` without external YAML parser dependencies.
fn yaml_board(text: &str) -> Option<String> {
let mut default_fqbn = None;
let mut default_profile = None;
let mut current_profile: Option<String> = None;
let mut profile_fqbns = std::collections::BTreeMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
let indent = line.chars().take_while(|c| c.is_whitespace()).count();
if let Some((k, v)) = trimmed.split_once(':') {
let key = k.trim();
let val = yaml_scalar(v);
if indent == 0 {
if key == "default_fqbn" && !val.is_empty() {
default_fqbn = Some(val.to_owned());
} else if key == "default_profile" && !val.is_empty() {
default_profile = Some(val.to_owned());
}
} else if indent <= 2 {
if key != "profiles" && val.is_empty() {
current_profile = Some(key.to_owned());
}
} else if key == "fqbn"
&& !val.is_empty()
&& let Some(p) = &current_profile
{
profile_fqbns.insert(p.clone(), val.to_owned());
}
}
}
if let Some(fqbn) = default_fqbn {
return Some(fqbn);
}
if let Some(prof) = default_profile
&& let Some(fqbn) = profile_fqbns.get(&prof)
{
return Some(fqbn.clone());
}
None
}
/// Resolves board FQBN from settings, `sketch.yaml`, or standard AVR Uno fallback.
pub fn board(_root: &Path, sketch: &Path, settings: &ArduinoSettings) -> IdeResult<String> {
let configured = settings.fqbn.trim();
if !configured.is_empty() {
return Ok(configured.to_owned());
}
let yaml = sketch.join("sketch.yaml");
if let Ok(text) = std::fs::read_to_string(&yaml)
&& let Some(fqbn) = yaml_board(&text)
{
return Ok(fqbn);
}
// Fall back to default board for the language server if not configured,
// so code intelligence functions immediately out of the box.
Ok("arduino:avr:uno".into())
}
fn argument<'a>(args: &'a [String], flag: &str) -> IdeResult<Option<&'a str>> {
for (index, arg) in args.iter().enumerate() {
let key = arg.trim_start_matches('-');
let value = if key == flag {
Some(args.get(index + 1).map(String::as_str).filter(|v| !v.starts_with('-')).unwrap_or(""))
} else {
key.strip_prefix(flag).and_then(|tail| tail.strip_prefix('='))
};
if let Some(value) = value {
if value.trim().is_empty() {
return Err(IdeError::invalid(format!("Arduino Language Server argument -{flag} requires a value.")));
}
return Ok(Some(value));
}
}
Ok(None)
}
fn set_path_argument(args: &mut [String], flag: &str, path: &Path) {
let value = path.to_string_lossy().into_owned();
for index in 0..args.len() {
let key = args[index].trim_start_matches('-');
if key == flag {
args[index + 1] = value;
return;
}
if key.strip_prefix(flag).is_some_and(|tail| tail.starts_with('=')) {
args[index] = format!("-{flag}={value}");
return;
}
}
}
fn configure_inner(cfg: &mut LanguageServerConfig, root: &Path, sketch: &Path, settings: &Settings) -> IdeResult<()> {
let arduino = &settings.languages.arduino;
let explicit_server = settings.language_servers.get(SERVER).is_some_and(|s| !s.command.trim().is_empty());
if !explicit_server && cfg.command == SERVER {
cfg.command = resolve_tool(root, &arduino.language_server, SERVER)?.to_string_lossy().into_owned();
} else {
cfg.command = resolve_tool(root, &cfg.command, SERVER)?.to_string_lossy().into_owned();
}
for (flag, configured, executable) in [("cli", &arduino.cli, "arduino-cli"), ("clangd", &arduino.clangd, "clangd")]
{
if let Some(value) = argument(&cfg.args, flag)? {
let path = resolve_tool(sketch, value, executable)?;
set_path_argument(&mut cfg.args, flag, &path);
} else {
let path = resolve_tool(root, configured, executable)?;
cfg.args.extend([format!("-{flag}"), path.to_string_lossy().into_owned()]);
}
}
if let Some(value) = argument(&cfg.args, "cli-config")? {
if let Some(path) = cli_config(sketch, value)? {
set_path_argument(&mut cfg.args, "cli-config", &path);
}
} else if let Some(path) = cli_config(root, &arduino.cli_config)? {
cfg.args.extend(["-cli-config".into(), path.to_string_lossy().into_owned()]);
}
if argument(&cfg.args, "fqbn")?.is_none() {
cfg.args.extend(["-fqbn".into(), board(root, sketch, arduino)?]);
}
Ok(())
}
/// Applies discovered Arduino CLI, clangd and board FQBN to the LanguageServerConfig.
pub fn configure(cfg: &mut LanguageServerConfig, root: &Path, sketch: &Path, settings: &Settings) {
if cfg.name != SERVER {
return;
}
if let Err(error) = configure_inner(cfg, root, sketch, settings) {
cfg.launch_error = Some(error.message);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_tool_arguments_are_resolved_before_launch() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let settings = Settings::default();
for name in [SERVER, "arduino-cli", "clangd", "arduino-cli.yaml"] {
std::fs::write(root.join(name), "").unwrap();
}
let registry = ide_language::LanguageRegistry::load(None).unwrap();
let language = registry.get(&ide_core::LanguageId::new("arduino")).unwrap();
for equals in [false, true] {
let mut cfg = language.language_servers[0].clone();
cfg.command = root.join(SERVER).to_string_lossy().into_owned();
for (flag, name) in [("cli", "arduino-cli"), ("clangd", "clangd"), ("cli-config", "arduino-cli.yaml")] {
if equals {
cfg.args.push(format!("-{flag}=./{name}"));
} else {
cfg.args.extend([format!("-{flag}"), format!("./{name}")]);
}
}
configure_inner(&mut cfg, root, root, &settings).unwrap();
for (flag, name) in [("cli", "arduino-cli"), ("clangd", "clangd"), ("cli-config", "arduino-cli.yaml")] {
assert_eq!(Path::new(argument(&cfg.args, flag).unwrap().unwrap()), root.join(name));
}
}
}
#[test]
fn parses_sketch_yaml_default_fqbn() {
let yaml = "default_fqbn: arduino:avr:nano\ndefault_port: COM3\n";
assert_eq!(yaml_board(yaml), Some("arduino:avr:nano".into()));
}
#[test]
fn sketch_yaml_scalar_comments_preserve_quoted_hashes() {
for (scalar, expected) in [
("arduino:avr:uno # board", Some("arduino:avr:uno")),
("arduino:avr:uno # board", Some("arduino:avr:uno")),
("arduino:avr:uno\t# board", Some("arduino:avr:uno")),
("arduino:avr:uno#board", Some("arduino:avr:uno#board")),
("'arduino:avr:uno # board'", Some("arduino:avr:uno # board")),
("\"arduino:avr:uno # board\"", Some("arduino:avr:uno # board")),
("'arduino:avr:uno # board' # comment", Some("arduino:avr:uno # board")),
("\"arduino:avr:uno # board\" # comment", Some("arduino:avr:uno # board")),
(r#""arduino:avr:uno \" # board" # comment"#, Some(r#"arduino:avr:uno \" # board"#)),
("'arduino:avr:uno '' # board' # comment", Some("arduino:avr:uno '' # board")),
("# no board", None),
] {
assert_eq!(yaml_board(&format!("default_fqbn: {scalar}\n")), expected.map(str::to_owned), "{scalar}");
}
}
#[test]
fn sketch_yaml_profile_comments_preserve_selection() {
let yaml = r#"
default_profile: esp32 # selected profile
profiles:
uno: # other profile
fqbn: arduino:avr:uno # other board
esp32: # selected profile
fqbn: esp32:esp32:esp32 # selected board
"#;
assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32".into()));
let yaml = r#"
default_profile: "esp32#profile" # selected profile
profiles:
esp32#profile:
fqbn: 'esp32:esp32:esp32 # board' # selected board
"#;
assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32 # board".into()));
}
#[test]
fn parses_sketch_yaml_profiles() {
let yaml = r#"
default_profile: esp32
profiles:
uno:
fqbn: arduino:avr:uno
esp32:
fqbn: esp32:esp32:esp32
"#;
assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32".into()));
}
#[test]
fn board_falls_back_to_avr_uno() {
let dir = tempfile::tempdir().unwrap();
let settings = ArduinoSettings::default();
let fqbn = board(dir.path(), dir.path(), &settings).unwrap();
assert_eq!(fqbn, "arduino:avr:uno");
}
#[test]
fn board_prefers_settings_fqbn() {
let dir = tempfile::tempdir().unwrap();
let settings = ArduinoSettings { fqbn: "arduino:samd:mkr1000".into(), ..Default::default() };
let fqbn = board(dir.path(), dir.path(), &settings).unwrap();
assert_eq!(fqbn, "arduino:samd:mkr1000");
}
}
+1
Переглянути файл
@@ -7,6 +7,7 @@
mod angular;
pub mod apply;
pub mod arduino;
mod discover;
mod exec;
pub mod go;
+113 -3
Переглянути файл
@@ -33,7 +33,7 @@ pub use jvm::{JvmPresentation, JvmTypeKind, jvm_type_header};
pub use types::*;
use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range};
use ide_language::{ParsedTree, SyntaxCache, TreeError};
use ide_language::{ParsedTree, Syntax, SyntaxCache, TreeError, shift_range};
use kinds::Kinds;
use lines::Lines;
use std::sync::Arc;
@@ -143,8 +143,15 @@ impl StructureService {
/// What the service can answer for `language`.
pub fn support(&self, language: &LanguageId) -> StructureSupport {
match self.cache.syntax().grammar_name(language) {
Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() },
let syn = self.cache.syntax();
// Web containers (Vue SFC / Svelte / JSP have no grammar of their own)
// answer from the HTML tree; inline <script> regions add the block
// grammar's tables — TypeScript / JavaScript ship locals.scm — so both
// capabilities are on whenever HTML is.
let g =
syn.grammar_name(language).or_else(|| (syn.web_grammar_name(language) == Some("html")).then_some("html"));
match g {
Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() || g == "html" },
None => StructureSupport::default(),
}
}
@@ -157,6 +164,17 @@ impl StructureService {
range: Range,
cancel: &CancellationToken,
) -> StructureResult<Option<NodeAt>> {
let bytes = Lines::new(snap.text).bytes(range);
if let Some(at) = self.with_script_tree(snap, bytes, cancel, |cx, local, base| {
Ok(nodes::node_at(cx, local).map(|at| shift_node_at(at, base)))
})? {
return Ok(at);
}
// Outside any <script> region a web container (template markup) has no
// identifier to offer: an empty answer, not an error.
if self.web_container(snap.language) {
return Ok(None);
}
self.run(snap, cancel, |cx| Ok(nodes::node_at(cx, range)))
}
@@ -178,6 +196,17 @@ impl StructureService {
/// Scope analysis for the identifier at `pos` (`locals.scm`).
pub fn locals(&self, snap: &Snapshot, pos: Pos, cancel: &CancellationToken) -> StructureResult<Locals> {
let byte = Lines::new(snap.text).byte(pos);
if let Some(locals) = self.with_script_tree(snap, (byte, byte), cancel, |cx, local, base| {
locals::locals(cx, local.start).map(|l| shift_locals(l, base))
})? {
return Ok(locals);
}
if self.web_container(snap.language) {
// Template markup: no TypeScript identifier here; the frontend
// treats `noIdentifier` as "nothing at this position".
return Ok(Locals::NoIdentifier);
}
self.run(snap, cancel, |cx| locals::locals(cx, pos))
}
@@ -255,6 +284,62 @@ impl StructureService {
Ok(ranges)
}
/// Whether `language` is an HTML container without a grammar of its own
/// (Vue SFC / Svelte / JSP): structure answers come from the HTML tree
/// plus the inline `<script>` regions' block grammars.
fn web_container(&self, language: &LanguageId) -> bool {
let syn = self.cache.syntax();
syn.grammar_name(language).is_none() && syn.web_grammar_name(language) == Some("html")
}
/// Runs `f` on the block grammar's tree when one of the document's inline
/// `<script>` regions covers `bytes`; `f` sees the region's `Ctx`, the
/// query converted into region coordinates, and the region's document
/// `base`. `Ok(None)` when no region covers `bytes`.
fn with_script_tree<R>(
&self,
snap: &Snapshot,
bytes: (usize, usize),
cancel: &CancellationToken,
f: impl FnOnce(&Ctx, Range, Pos) -> StructureResult<R>,
) -> StructureResult<Option<R>> {
if cancel.is_cancelled() {
return Err(StructureError::Cancelled);
}
let cancelled = || cancel.is_cancelled();
let syn = self.cache.syntax();
if syn.web_grammar_name(snap.language) != Some("html") {
return Ok(None);
}
let extract = |p: ParsedTree<'_>| Syntax::script_regions_of(p.tree, p.text);
let regions = match snap.doc {
Some(doc) if syn.grammar_name(snap.language).is_some() => {
self.cache.with_parsed(doc, snap.language, snap.version, snap.text, &cancelled, extract)
}
_ => syn.with_parsed(&LanguageId::new("html"), snap.text, &cancelled, extract),
};
let Some(region) = regions?.into_iter().find(|r| bytes.0 >= r.base && bytes.1 <= r.base + r.text.len()) else {
return Ok(None);
};
let base = Lines::new(snap.text).pos(region.base);
let (a, b) = (bytes.0 - region.base, bytes.1 - region.base);
syn.with_parsed(&region.language, &region.text, &cancelled, |p| {
let cx = Ctx {
grammar: p.grammar,
language: p.language,
tree: p.tree,
kinds: Kinds::for_grammar(p.grammar, p.language),
lines: Lines::new(p.text),
cancel,
};
let local = Range::new(cx.lines.pos(a), cx.lines.pos(b));
f(&cx, local, base)
})
.map_err(StructureError::from)
.and_then(|r| r)
.map(Some)
}
fn run<R>(
&self,
snap: &Snapshot,
@@ -288,3 +373,28 @@ fn utf16_offset(text: &str, pos: Pos) -> u32 {
let lines = Lines::new(text);
lines::utf16_len(&text[..lines.byte(pos)])
}
/// Shifts a `<script>` region's answer to document coordinates (`base` is the
/// region start): the node and every ancestor move by [`shift_range`].
fn shift_node_at(at: NodeAt, base: Pos) -> NodeAt {
let shift = |n: SyntaxNode| SyntaxNode { range: shift_range(n.range, base), ..n };
NodeAt { node: shift(at.node), ancestors: at.ancestors.into_iter().map(shift).collect() }
}
/// [`shift_node_at`] for a `locals` answer: every range moves (`NotLocal`'s,
/// the symbol's definition, scope, references and conflicts), names stay.
fn shift_locals(l: Locals, base: Pos) -> Locals {
match l {
Locals::NoIdentifier => Locals::NoIdentifier,
Locals::NotLocal { name, range, reason } => Locals::NotLocal { name, range: shift_range(range, base), reason },
Locals::Local { symbol } => Locals::Local {
symbol: LocalSymbol {
definition: shift_range(symbol.definition, base),
scope: shift_range(symbol.scope, base),
references: symbol.references.into_iter().map(|r| shift_range(r, base)).collect(),
conflicts: symbol.conflicts.into_iter().map(|r| shift_range(r, base)).collect(),
..symbol
},
},
}
}
+120
Переглянути файл
@@ -287,3 +287,123 @@ fn identifiers_in_files_loads_in_parallel_and_skips_misses() {
let got: Vec<(String, usize)> = found.iter().map(|f| (f.path.display().to_string(), f.occurrences.len())).collect();
assert_eq!(got, [("/p/a.ts".to_owned(), 1), ("/p/d.rs".to_owned(), 2)]);
}
// ---------------------------------------------------------------------------
// Vue SFC / HTML: answers from the inline <script> regions' block grammars
#[test]
fn web_containers_support_the_tree_and_locals() {
let s = service();
for id in ["vue", "html"] {
let support = s.support(&lang(id));
assert!(support.tree, "{id}");
assert!(support.locals, "{id}");
}
let rust = s.support(&lang("rust"));
assert!(rust.tree && rust.locals, "languages with their own grammar are unchanged");
let plain = s.support(&lang("plaintext"));
assert!(!plain.tree && !plain.locals);
}
#[test]
fn node_at_inside_a_script_region_answers_from_the_block_grammar() {
let src = "<template>\n <div>{{ n }}</div>\n</template>\n<script lang=\"ts\">\nconst size = 1;\n</script>\n";
let vue = lang("vue");
// `size` inside the script: a TypeScript identifier, at document coordinates.
let at = service().node_at(&snap(&vue, src), occ(src, "size", 1), &none()).unwrap().unwrap();
assert_eq!(at.node.kind, "identifier");
assert_eq!(at.node.range, occ(src, "size", 1), "document coordinates");
assert_eq!(at.node.field.as_deref(), Some("name"));
// Template markup is not TypeScript: an empty answer, not markup nodes.
assert!(service().node_at(&snap(&vue, src), occ(src, "div", 1), &none()).unwrap().is_none());
}
#[test]
fn locals_inside_a_script_region_shift_every_range() {
let src = "<template><div/></template>\n<script lang=\"ts\">\nfunction f() {\n let count = 0;\n count += 1;\n}\n</script>\n";
let vue = lang("vue");
let byte = occurrences(src, "count")[0];
let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap();
let Locals::Local { symbol } = l else {
panic!("expected a local, got {l:?}");
};
assert_eq!(symbol.name, "count");
assert!(symbol.certain);
assert_eq!(symbol.definition, occ(src, "count", 1), "definition in document coordinates");
assert_eq!(symbol.references, vec![occ(src, "count", 2)], "references in document coordinates");
// Template: no TypeScript identifier here, so nothing local.
let byte = occurrences(src, "div")[0];
let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap();
assert!(matches!(l, Locals::NoIdentifier));
}
#[test]
fn web_script_queries_honour_cancellation_outside_regions() {
let cancel = CancellationToken::new();
cancel.cancel();
for id in ["html", "vue", "svelte", "jsp"] {
let language = lang(id);
let src = "<template>中文😀</template><script>let value = 1;</script>";
let snap = snap(&language, src);
assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 2)), &cancel), Err(StructureError::Cancelled));
assert_eq!(service().locals(&snap, Pos::new(0, 2), &cancel), Err(StructureError::Cancelled));
}
}
#[test]
fn web_script_queries_compose_utf16_positions_and_isolate_blocks() {
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { let 名 = '😀'; 名; }</script>\r\n<script setup lang=ts>function second() {\r\nlet 名 = 2;\r\n名;\r\n}</script>";
for id in ["html", "vue", "svelte", "jsp"] {
let language = lang(id);
for (at, def, reference) in [(2, 1, 2), (4, 3, 4)] {
assert_local(id, src, "名", at, def, &[reference]);
let r = occ(src, "名", at);
let node = service().node_at(&snap(&language, src), r, &none()).unwrap().unwrap();
assert_eq!(node.node.range, r);
assert_eq!(node.node.kind, "identifier");
}
}
}
#[test]
fn web_script_queries_follow_open_document_edits() {
use ide_core::{ChangeSet, TextChange};
for id in ["html", "vue", "svelte", "jsp"] {
let cache = Arc::new(SyntaxCache::new(Arc::new(Syntax::new())));
let service = StructureService::new(cache.clone());
let language = lang(id);
let id = DocId::next();
let mut doc = ide_text::Document::new("<script>function f() { let x = 1; x; }</script>");
cache.open(id, &language, 0);
for version in 0..2 {
let text = doc.text();
let snap = Snapshot::doc(id, doc.version(), &language, &text);
let range = occ(&text, "x", 2);
let at = service.node_at(&snap, range, &none()).unwrap().unwrap();
assert_eq!(at.node.range, range);
let Locals::Local { symbol } = service.locals(&snap, range.start, &none()).unwrap() else {
panic!("expected local");
};
assert_eq!(symbol.definition, occ(&text, "x", 1));
assert_eq!(symbol.references, [range]);
if version == 0 {
let changes =
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "<!-- 中文😀 -->\n".into() }] };
let (_, edits) = doc.apply_detailed(doc.version(), &changes).unwrap();
cache.edit(id, doc.version(), &edits);
}
}
}
}
#[test]
fn web_script_queries_preserve_parse_size_errors() {
let src = " ".repeat(ide_language::MAX_PARSE_BYTES + 1);
for id in ["html", "vue", "svelte", "jsp"] {
let language = lang(id);
let snap = snap(&language, &src);
assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 0)), &none()), Err(StructureError::TooLarge));
assert_eq!(service().locals(&snap, Pos::new(0, 0), &none()), Err(StructureError::TooLarge));
}
}
+24
Переглянути файл
@@ -0,0 +1,24 @@
# Data directories
OpenIDEA keeps its rebuildable data, such as downloaded tools, language servers,
JDKs and index caches, in the operating system's local data directory:
- **Windows:** `%LOCALAPPDATA%\ai.l0ng.openidea`
- **macOS:** `~/Library/Application Support/ai.l0ng.openidea`
- **Linux:** `~/.local/share/ai.l0ng.openidea`
To keep this data elsewhere, for example on another drive, set
`OPENIDEA_LOCAL_DATA_DIR` to an absolute path before starting OpenIDEA. The IDE
then uses `<OPENIDEA_LOCAL_DATA_DIR>/ai.l0ng.openidea` and creates it when
missing. If the path is relative or cannot be written, OpenIDEA shows an error
and exits instead of falling back to the default location.
```bat
setx OPENIDEA_LOCAL_DATA_DIR "E:\OpenIDEA"
```
Settings, plugins, scratches and other user data stay in the operating system's
data and configuration directories. Logs stay in their usual location, so they
are still written when the variable is wrong. Existing data is not moved: quit
OpenIDEA and move the old directory's contents to the new location, or let the
IDE download tools and rebuild indexes again.
+89 -3
Переглянути файл
@@ -48,11 +48,12 @@ const REPLY = [
"```",
].join("\n");
async function openReply(page: Page, text: string, imageData?: string) {
async function openReply(page: Page, text: string, imageData?: string, theme?: "dark" | "light") {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
await page.evaluate(
async ({ task, text, imageData }) => {
async ({ task, text, imageData, theme }) => {
if (theme) document.documentElement.dataset.theme = theme;
if (imageData) {
const routePath = "/src/platform/tauriCore.ts";
const { setInvokeRoute } = await import(routePath);
@@ -80,7 +81,7 @@ async function openReply(page: Page, text: string, imageData?: string) {
});
store.dockTask(task.id);
},
{ task: TASK, text, imageData },
{ task: TASK, text, imageData, theme },
);
const reply = page.locator("[class*='_markdown_']").first();
await reply.locator("h1, p").first().waitFor();
@@ -360,3 +361,88 @@ test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewritin
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
});
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
const flow = [
"```mermaid",
"flowchart LR",
' subgraph Client["Client"]',
' A["Local agent"]',
' B["Voice input"]',
" end",
' subgraph Cloud["Cloud"]',
' C["Cloud agent"]',
' D["Memory service"]',
" end",
" B --> A",
" A --> C",
" C --> D",
"```",
].join("\n");
const sequence = ["```mermaid", "sequenceDiagram", " loop every minute", " A->>B: ping", " end", "```"].join(
"\n",
);
const reply = await openReply(page, `Flow:\n\n${flow}\n\nSequence:\n\n${sequence}`);
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(2);
expect(await css(reply, ".node rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(reply, ".labelBox", "fill")).not.toBe("rgb(0, 0, 0)");
});
const DIAGRAMS = [
"flowchart LR\n subgraph Client\n A[Local agent] --> B[Voice input]\n end",
"sequenceDiagram\n loop every minute\n A->>B: ping\n end",
"classDiagram\n class Agent {\n +String name\n +run()\n }",
"stateDiagram-v2\n [*] --> Ready\n Ready --> Running\n Running --> [*]",
'pie title Work\n "Ready" : 40\n "Running" : 35\n "Done" : 25',
"gantt\n dateFormat YYYY-MM-DD\n section Delivery\n Shipped :done, shipped, 2026-01-01, 5d\n Building :active, building, after shipped, 5d\n Next :next, after building, 5d\n Critical :crit, critical, after next, 5d",
]
.map((source) => `\`\`\`mermaid\n${source}\n\`\`\``)
.join("\n\n");
for (const theme of ["dark", "light"] as const) {
test(`keeps Mermaid shapes, labels and chart series readable in ${theme} theme`, async ({ page }) => {
const reply = await openReply(page, `Diagrams:\n\n${DIAGRAMS}`, undefined, theme);
const svgs = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
await expect(svgs).toHaveCount(6);
for (const [index, selector] of [
[0, ".node rect"],
[1, ".labelBox"],
[2, ".outer-path path"],
[3, ".node rect"],
] as const) {
expect(await css(svgs.nth(index), selector, "fill")).not.toBe("rgb(0, 0, 0)");
expect(await css(svgs.nth(index), selector, "stroke")).not.toContain("url(");
}
expect(await css(svgs.nth(2), ".nodeLabel", "color")).not.toBe(await css(svgs.nth(2), ".outer-path path", "fill"));
const slices = await svgs
.nth(4)
.locator(".pieCircle")
.evaluateAll((els) => els.map((el) => getComputedStyle(el).fill));
expect(new Set(slices).size).toBe(3);
expect(slices).not.toContain("rgb(0, 0, 0)");
const contrasts = await svgs
.nth(5)
.locator("text.taskText")
.evaluateAll((els) => {
const luminance = (color: string) => {
const [r, g, b] = color
.match(/\d+/g)!
.map(Number)
.map((v) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
};
return els.map((el) => {
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
const fg = luminance(getComputedStyle(el).fill);
const bg = luminance(getComputedStyle(bar).fill);
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
});
});
expect(contrasts).toHaveLength(4);
for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5);
});
}
+37 -1
Переглянути файл
@@ -1,4 +1,40 @@
import { expect, test } from "./fixtures";
import { expect, stubDesktop, test } from "./fixtures";
for (const windowKind of ["project", "welcome", "lightEdit"]) {
test(`manual update answers stay visible in ${windowKind} with saved Notifications layout and logging off`, async ({
workbench: page,
}) => {
await stubDesktop(page);
await page.evaluate(async (kind) => {
const projectPath = "/src/platform/project.ts";
const layoutPath = "/src/workbench/toolwindows/store.ts";
const updatesPath = "/src/features/app/updates.tsx";
const { useProject } = await import(/* @vite-ignore */ projectPath);
const { useToolWindowLayout } = await import(/* @vite-ignore */ layoutPath);
const { checkForUpdates } = await import(/* @vite-ignore */ updatesPath);
const settings = useProject.getState().settings;
useProject.setState({
...(kind === "project" ? {} : { project: null, lightEdit: kind === "lightEdit" }),
settings: {
...settings,
notifications: { balloons: false, groups: { updates: { display: "none", log: false } } },
},
});
useToolWindowLayout.setState({
visible: { ...useToolWindowLayout.getState().visible, right: "notifications" },
maximized: null,
});
window.__ipc.handlers.app_check_update = () => ({
enabled: true,
update: { version: "99.0.0", currentVersion: "0.1.40", notes: null, date: null },
});
window.__ipc.handlers.app_update_announcement = () => true;
await checkForUpdates();
}, windowKind);
await expect(page.getByRole("status").filter({ hasText: "OpenIDEA 99.0.0 is available" })).toBeVisible();
await expect(page.getByRole("button", { name: "Install and Restart", exact: true })).toBeVisible();
});
}
test("Updates shows check status and keeps manual checks available when automatic checks are off", async ({
workbench: page,
+21 -2
Переглянути файл
@@ -1,10 +1,29 @@
id = "arduino"
name = "Arduino"
# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile
# them on its own, so they get C++ highlighting without a language server.
# Arduino Language Server supplies Arduino's generated compile context to clangd.
extensions = ["ino"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "C++"
tree-sitter = "cpp"
[[language-servers]]
name = "arduino-language-server"
command = "arduino-language-server"
root-markers = ["*.ino", "sketch.yaml"]
require-root-marker = true
features = [
"diagnostics",
"completion",
"hover",
"signature-help",
"definition",
"type-definition",
"implementation",
"document-highlight",
"formatting",
"rename",
"code-action",
]
install-hint = "Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml."
+38 -4
Переглянути файл
@@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.4.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.5.0`, world
`plugin`).
## 1. Quick start
@@ -365,9 +365,19 @@ are always available. Calling a gated function without its capability returns
## 3. Writing code (Rust SDK)
### API 1.5
API 1.5.0 is planned for the next OpenIDEA release. Use
`engines.openidea = "^1.5"` and the matching SDK. Released 1.4 plugins keep
working. New in 1.5:
- [Notifications for a project](#notifications-with-commands):
`Project::notify` shows a notification in a window of that project only,
also from scheduled or background work.
### API 1.4
API 1.4.0 is planned for the next OpenIDEA release. Use
API 1.4.0 shipped with OpenIDEA 0.1.40. Use
`engines.openidea = "^1.4"` and the matching SDK. Released 1.3 plugins keep
working. New in 1.4:
@@ -525,6 +535,7 @@ then run `openidea-plugin check`.
| `host::output_append(channel, text)`, `output_clear`, `output_reveal` | `output.{append,clear,reveal}` | API 1.4: read-only Output tabs; 32 named channels per plugin, each a 1 MiB UTF-8 ring buffer. Reveal requires the invoking window. |
| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. |
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `Project::notify(level, title, body, actions)` | `ui.notify-project` | API 1.5: balloon only in a window of that project (the most recently focused one), with or without an invoking window; see _Notifications with commands_. |
| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. |
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. |
@@ -2254,8 +2265,8 @@ the API itself follows _Evolving the plugin API_ below.
## 10. Evolving the plugin API
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0
with 0.1.37, 1.2.0 with 0.1.38 and 1.3.0 with 0.1.39. API 1.4.0 is planned
with the next release. wasmtime decides what stays compatible: it resolves interface names
with 0.1.37, 1.2.0 with 0.1.38, 1.3.0 with 0.1.39 and 1.4.0 with 0.1.40.
API 1.5.0 is planned with the next release. wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
@@ -2382,6 +2393,29 @@ its normal editor and project context. No capability is needed. Invalid or
undeclared actions reject the whole notification. Labels are limited to 256
bytes and arguments to 64 KiB. Plain `host::notify` behaves as before.
Both go to the window the command or event came from, or to every window when
there is none (a scheduled call, for instance). To reach one project from such
work, call `notify` on that [`Project`](#the-api) (API 1.5, `ui.notify-project`),
with the same actions and limits:
```rust
for project in host::projects()? {
if let Some(update) = check_for_update(&project)? {
let disable = NotificationAction::new("Disable automatic checks", "disable-checks")
.argument(project.root()?);
project.notify(NotificationLevel::Info, "Update available", &update, &[disable])?;
}
}
```
- It shows only in a window of that project: the most recently focused one
when the project is open in several.
- A button runs its command with an invocation from that window, so
`ctx.project()` is the notification's project.
- If the project has closed by the time of the call, the call fails with
`Error::NotFound` and shows nothing; it never falls back to another window.
A balloon already shown goes away with its window.
### Selecting plugin tests (API 1.4, unreleased)
A plugin using `framework = "plugin"` can implement `TestResults::select_tests`
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e3c11d8969a1b8b52e7e745b558d12f804ebf533e7f0f3fe614ffe6f7b9c9468
bb67a1b003840ecc2d0c056a0b0af3474b08ac095239c1f4d02b7a7aab5b526c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b28ccfd442c6348e0826680ec97391cbe4ec6a93a27ff02e40f47341e21586b4
a6289739f5b644ea559ba18298741205ea0bedce7bd1dd2b367309101e8ffb1e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d669a2600b0c59e91ac8a9b875a24bddf0eeddd404ce7b67da31733861a846b8
767ff8329456fe3a1f324f571b22124707244a61ff5b6ea2c7ed88f909d202e8
Бінарний файл не відображається.
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more