Compare commits

...
Author SHA1 Message Date
l0ng-ai d68315131f fix(dev): apply the worktree's Vite URL when dev:app is given a project path
`pnpm dev:app <path>` appended `--config {devUrl}` after the path, and `tauri dev`
hands the first positional argument and everything after it to the app. The config was
dropped: tauri ran `pnpm dev` on port 1420 as well, and a full reload loaded that server
instead of the worktree's, failing with "Could not load startup script". The config now goes
before the first positional argument.
2026-11-14 01:10:07 +08:00
Akbar Husain 1392e824bb perf(index): use compact query tables for faster file and symbol search (#3209)
Index queries (find_files, find_dirs, find_symbols) walked FileEntry values and per-file Box<str> / Box<[Sym]> allocations on every keystroke. They now scan compact tables kept in sync under the IndexData write lock: packed file rows and interned path/symbol arenas, a flat symbol table, precomputed masks, an ASCII fast path, a per-file union of symbol masks to skip files, and O(1) recents lookup. Ranking, scores, match indices, recency boosts, cancellation and incremental updates are unchanged.

Criterion (index_100k), main vs this change: find_files 45-60% faster (zzzz flat), find_dirs 20-48% faster (a flat), find_symbols 50-61% faster except OCo (flat).

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 01:08:30 +08:00
zhangdagouandPLZY 6073f35eb6 feat(debugger): import classes the frame's file doesn't import in Java Evaluate (#3204)
Evaluating an expression that uses a class the breakpoint's file doesn't import (`JSON.toJSONString(u)`) failed with "JSON cannot be resolved". java-debug only takes the expression's text, so a class it reports as unresolved is looked up through workspace symbols and written fully qualified: one accessible class with the name is imported at once, several are offered to choose from. Choosing a class in completion inserts the short name and imports it. Imports last for the debug session and apply to Watches and the Debug Console too.

Constructor calls (`new Foo()`) are qualified as well, and private nested classes (java.util.Arrays.ArrayList) are not offered.

Co-authored-by: PLZY <[email protected]>
2026-11-14 00:58:46 +08:00
zhangdagouandPLZY b02964962b fix(vcs): show the time of older commits in the Git Log (#3206)
With pretty dates on, commits older than yesterday showed only the date; they now show date and time (numeric date, e.g. 2026/11/11 22:43), and the same format is used with pretty dates off. Today, Yesterday and N min ago are unchanged. The date column's default width grows from 120 to 140 px so a 12-hour date and time ("11/05/2026 02:30 PM", "Yesterday 09:07 AM") is no longer cut off.

Co-authored-by: PLZY <[email protected]>
2026-11-14 00:52:49 +08:00
l0ng-ai 9ca8097f3c fix(lsp): offer an update when a language server build has expired (#3210)
JetBrains preview builds of kotlin-lsp stop starting once their date passes (exit code 7). The server was restarted three times and a manual restart only reported the same failure. A server that reports an expired build is no longer restarted, and the tools feature offers the latest release (Update for the IDE's copy, Install Latest for one on PATH, the Language Servers page for a chosen executable).

Fixes #2925
2026-11-14 00:45:51 +08:00
l0ng-ai 1b08d5de09 feat(diff): Ctrl/Cmd-click navigation and quick docs in the working-copy pane (#3203)
A diff pane showing a file's open document (working copy, Compare Files) gets that document's hover / quick docs and Ctrl/Cmd-click navigation; the target opens in an editor tab and the diff stays. Editor contributions gain diffExtensions and document attachments expose their context.
2026-11-14 00:20:57 +08:00
Alexanderandformiat 8faedce175 feat(vcs): show favorite branches first in the Git Log branch filter (#3201)
The Git Log Branch filter now lists HEAD, then the favorite branches (local and remote, by name, marked with a star), then Local and Remote as collapsed groups that open with a click, Enter or Right and close with Left. A search still lists every match under Local and Remote. Nested branches are indented with the tree indent.

Co-authored-by: formiat <[email protected]>
2026-11-14 00:11:16 +08:00
Muuandmuutot c8bdfa8edd feat(plugins): a host-owned diagnostics snapshot and line-end annotation order (#3103)
Plugins read problems from a host-owned snapshot that records every Diagnostics event per file and producer, instead of the UI-reported Problems reports, so what a plugin sees no longer depends on window lifetime or the invoking window. Adds the problems-changed workbench event (plugin API 1.8.0, gated at needs_api 8), editor focus events for files of other windows, severity style previews in plugin settings, and lays plugin annotations on the line-end chain before code-vision hints and inline blame. Components and fixtures are rebuilt for API 1.8.0.

Co-authored-by: muutot <[email protected]>
2026-11-14 00:07:03 +08:00
Mamaev Igor 4d22632609 fix(gutter): keep plugin gutter buttons while editing (#3196)
Plugin gutter actions (Run buttons from language plugins) disappeared on every edit and came back after the 300 ms refresh: the actions field was emptied on any document change, a provider change cleared everything at once, and a failed refresh removed them. The buttons now map through edits, a provider change only schedules a refresh, failures keep the last buttons, and the gutter column is reconfigured only when it appears or goes away.

Co-authored-by: pxc1984 <[email protected]>
2026-11-13 23:56:02 +08:00
Muuandmuutot 7f61b7b8ab fix(themes): give the bundled theme the new typo and deprecated tokens (#3199)
#3154 added --wave-typo and --fg-deprecated to tokens.css but not to the bundled Harbor Night scheme, which must set every editor token, and plugins/themes.md was stale. Adds both with the dark values from tokens.css and regenerates the theme docs.

Co-authored-by: muutot <[email protected]>
2026-11-13 23:52:32 +08:00
l0ng-ai abe30914b5 fix(remote): never reuse an older openidea-server after an update (#3202)
A server binary at <data dir>/server/<os>-<arch>/openidea-server was preferred over this version's download and never checked, so after an update remote windows kept installing the old server and new commands failed with -32601. The server now comes from OPENIDEA_SERVER_BINARY, the one next to the app, or this version's download (server/<version>/<os>-<arch>/); the unversioned location is an offline fallback only. A command missing on a server of another version names both versions.
2026-11-13 23:51:33 +08:00
Alexanderandformiat 1ae3edd5c8 fix(vcs): keep Git Branches' expanded groups and folders across restarts (#3182)
Git Branches kept the groups and folders the user expanded or collapsed only for the session. The toggled keys, per popup kind, are now stored per project root in localStorage and loaded when the popup opens; untouched keys keep their defaults.

Co-authored-by: formiat <[email protected]>
2026-11-13 23:33:16 +08:00
Alexanderandformiat 52b8cf5e70 fix(vcs): close Git Log toolbar dropdowns on a second click (#3193)
A second click on a Git Log toolbar button reopened its menu (or left the Branch popup open): the menus opened as the global context menu at a point, which took the press on the button for an outside click. They now open as a Menu anchored to the button that toggles on the same button, covering the filter bar, Graph/View Options and the commit details menus; the Branch popup toggles too.

Co-authored-by: formiat <[email protected]>
2026-11-13 23:32:30 +08:00
l0ng-ai 258ba189c6 feat(appearance): background image for the project window (#3194)
Settings › Appearance › Background Image picks an image shown through the project window, with opacity (default 15%), fill (scale, original size, tile) and anchor. Drawn over the workbench under dialogs and popups without taking clicks; the settings row previews the image and shows why a path cannot be loaded.
2026-11-13 23:19:17 +08:00
l0ng-ai f3d0417f4c feat(languages): Slint with the slint-lsp language server (#3191)
Adds a Slint language definition (.slint) served by slint-lsp, installable from the slint-ui/slint GitHub releases (macOS universal, Linux and Windows x64/arm64).
2026-11-13 23:09:26 +08:00
l0ng-ai 291d1324c5 style(agents): borderless task search that sits in the list like a tree row 2026-11-13 22:44:34 +08:00
l0ng-ai a6e137a6ae feat(terminal): move a terminal tab to the editor area (#3187)
A Terminal tool window tab can move into the editor area with its session still running (Move to Editor). Restored editor terminal tabs start a login shell where the session was. The New CLI launcher page, saved CLIs and the cli.new action are removed.
2026-11-13 22:44:17 +08:00
l0ng-ai 89f00a29f9 feat(agents): reference chips, quote blocks and a richer completion in the composer (#3091)
The agent composer is now a CodeMirror editor: the text stays plain and is what's sent, while @ file references are drawn as chips (icon, name, lines) that move and delete as one piece, open on click and preview on hover; Backspace after a chip opens it as text. Quotes show as blocks, and sent messages get the same chips and quote blocks. The @ completion lists open editors first, highlights matches and shows the folder; the focused editor's file is offered above the text, a pasted project path becomes a reference, and dropping files shows a hint. Lines copied from an editor paste as a reference, terminal output as a terminal block, and code blocks show as cards. The reference trimming regex avoids lookbehind for WebKit before 16.4.
2026-11-13 22:29:59 +08:00
l0ng-ai 834f9e172c test(i18n): allow CLI to stay untranslated (#3186)
The New CLI tab added "CLI" to the text catalogs; like Shell and other abbreviations it reads the same in every locale, so the catalog test lists it with them.
2026-11-13 22:27:11 +08:00
281 changed files with 7088 additions and 1744 deletions

No files matched your search

+23
View File
@@ -673,6 +673,28 @@ pub struct AppearanceSettings {
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
/// Appearance › Background Image: an image shown through the IDE window.
pub background_image: BackgroundImageSettings,
}
/// Appearance › Background Image. An empty `path` shows no image.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct BackgroundImageSettings {
pub path: String,
/// Percent, 0–100.
pub opacity: u8,
/// `scale` (cover the window) | `plain` (original size) | `tile`.
pub fill: String,
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
/// `bottom-left`, `bottom`, `bottom-right`.
pub anchor: String,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
}
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
@@ -743,6 +765,7 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
+7 -1
View File
@@ -34,7 +34,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.7.0";
pub const ENGINE_VERSION: &str = "1.8.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.
@@ -166,6 +166,7 @@ pub const EVENT_KINDS: &[&str] = &[
"editor-focused",
"action-performed",
"vcs-changed",
"problems-changed",
];
impl std::fmt::Display for ActivationEvent {
@@ -372,6 +373,11 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 6)?;
}
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
// that carries it.
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
self.needs_api(&format!("`{event}`"), 8)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
+78
View File
@@ -0,0 +1,78 @@
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
//! emitted it and whatever window it was addressed to. Plugins read this instead of
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
//! window lifetime, the invoking window's project, or the report loop's liveness.
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
impl DiagnosticsStore {
/// Full replacement for one (file, producer), like the event itself.
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
let mut map = self.0.write();
let sources = map.entry(path.clone()).or_default();
if items.is_empty() {
sources.remove(source);
if sources.is_empty() {
map.remove(path);
}
} else {
sources.insert(source.to_owned(), items);
}
}
/// Every file's diagnostics as one entry per producer.
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
let map = self.0.read();
map.iter()
.flat_map(|(path, sources)| {
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
})
.collect()
}
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
self.0.read().get(path).cloned().unwrap_or_default()
}
/// Drops the files under `root` (their project closed; the producers will re-report
/// on open). Returns the number of files removed.
pub fn forget_root(&self, root: &PathBuf) -> usize {
let mut map = self.0.write();
let before = map.len();
map.retain(|path, _| !path.starts_with(root));
before - map.len()
}
}
/// Records `Diagnostics` events, then delivers them as usual.
pub struct RecordingSink {
pub inner: SharedSink,
pub store: Arc<DiagnosticsStore>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: IdeEvent) {
if let IdeEvent::Diagnostics { path, source, items } = &event {
self.store.replace(&path.0.clone(), source, items.clone());
}
self.inner.emit(event);
}
}
/// A sink whose diagnostics are recorded; the store outlives every project.
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
/// Wraps a sink factory so every sink it creates records diagnostics.
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
Box::new(move |window| {
let inner = inner(window);
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
})
}
+11
View File
@@ -5,6 +5,7 @@
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -58,6 +59,9 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
/// Every `Diagnostics` event the host ever emitted, per file and producer:
/// the snapshot plugins read (`problems`), independent of windows and reports.
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
@@ -135,12 +139,16 @@ impl Host {
runtime,
project_environment,
} = config;
// Every sink the host hands out records diagnostics into the shared store first.
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
diagnostics,
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
@@ -288,6 +296,9 @@ impl Host {
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
// The producers will re-report when a project reopens; until then the snapshot
// must not answer for files whose project is gone.
self.diagnostics.forget_root(&project.root);
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
+28 -4
View File
@@ -47,14 +47,17 @@ pub async fn spell_check(
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let path = project.documents.snapshot(doc)?.path.0.clone();
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
return Ok((path, Vec::new()));
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
return Ok((path, Vec::new()));
};
found
.into_iter()
.map(|r| Region {
@@ -72,11 +75,32 @@ pub async fn spell_check(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
let typos =
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
Ok((path, typos))
})
.await;
host.end_request(request);
result
// Record the typos in the host's diagnostics snapshot so line-end annotations
// see them without waiting for a UI report; the editor still draws from its
// own call above, unchanged.
if let Ok((path, typos)) = &result {
let items: Vec<ide_core::Diagnostic> = typos
.iter()
.map(|t| ide_core::Diagnostic {
range: t.range,
severity: ide_core::Severity::Info,
message: format!("Typo: In word '{}'", t.word),
source: Some("Spelling".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.collect();
host.diagnostics.replace(path, "Spelling", items);
}
result.map(|(_, typos)| typos)
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
+55 -26
View File
@@ -3176,26 +3176,17 @@ impl PluginWorkspace for HostWorkspace {
Ok(out)
}
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
// The host records every `Diagnostics` event it emits (language servers,
// builds, spelling, plugin providers), so this sees a file's problems
// whatever window opened it — not just the ones its UI reported.
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
let root = host.project(client).map_err(failed)?.root.clone();
let windows = host.windows_for_root(&root);
host.project(client).map_err(failed)?;
let mut out = Vec::new();
for file in host.mcp_reports.problems(&windows) {
if path.is_some_and(|p| p != file.path.0) {
for (file, source, diagnostics) in host.diagnostics.problems() {
if path.is_some_and(|p| p != file) {
continue;
}
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
continue;
}
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
for d in file.items {
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
}
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
path: to_ipc(&file.path.0),
source,
diagnostics,
}));
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
}
Ok(out)
}
@@ -3321,6 +3312,8 @@ impl PluginWorkspace for HostWorkspace {
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
// Any open project may publish problems for any file: annotations follow the
// file, not the window (an editor can show a file outside every project root).
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
@@ -3332,7 +3325,8 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
.or_else(|| projects.first())
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
@@ -4135,16 +4129,23 @@ impl PluginApi {
return Err(HostError::Invalid("reversed decoration range".into()));
}
let replacement = if let Some(path) = path {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
let (root, real) = workspace
.projects()
.into_iter()
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
.max_by_key(|(root, _)| root.components().count())
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
Some(super::DecorationSet {
root: root.into(),
path: real.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
@@ -4188,6 +4189,8 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4308,8 +4311,11 @@ mod workbench_tests {
};
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
// Decorations follow the file: a path outside the open project is accepted too.
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
api.decorations(None, "set", vec![]).unwrap();
assert!(api.shared.decoration_sets()["test"].is_empty());
for i in 0..50 {
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
}
@@ -4350,6 +4356,29 @@ mod workbench_tests {
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
#[test]
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
// Inspection Lens colours typos and weak warnings with their own styles; the
// host must keep them, not silently drop them the way an unknown style is dropped.
for style in [
"highlight",
"search",
"added",
"modified",
"removed",
"warning",
"error",
"info",
"typo",
"weak",
"underline",
"strikethrough",
"muted",
] {
assert!(decoration_style(style), "{style} should be a known style");
}
assert!(!decoration_style("unknown"));
}
fn git(root: &Path, args: &[&str]) {
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+19 -9
View File
@@ -129,6 +129,7 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
@@ -136,16 +137,25 @@ impl PluginHost {
return None;
}
let workspace = workspace.as_ref()?;
let real = ide_plugin::resolve_real(path);
// The file's own project, not the invoking window's: annotations follow
// the file, so a plugin hears about a file another window has open.
let root = match client {
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
Some(client) => workspace
.project_of(client)
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.or_else(|| {
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}),
None => workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count()),
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
+26
View File
@@ -352,6 +352,15 @@ where
deserializer.deserialize_map(Properties)
}
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
/// message an Inspection Lens severity produces.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct SettingExample {
pub text: String,
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
pub style: String,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -371,6 +380,8 @@ pub struct SettingSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<SettingExample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
@@ -455,6 +466,20 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let example = match o.get("example") {
None => None,
Some(v) => {
let e: SettingExample = serde_json::from_value(v.clone())
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
if e.text.is_empty() || e.text.len() > 300 {
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
}
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
}
Some(e)
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
@@ -464,6 +489,7 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
example,
items,
properties,
};
@@ -430,3 +430,61 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
host.shutdown().await;
set_editor_providers(None);
}
/// A host with a project open: `problems` and `decorations` need a workspace.
fn harness(root: &Path) -> Arc<Host> {
let host = Arc::new(Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", root).unwrap();
host
}
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
plugins
}
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `decorate` publishes both styles; the host drops an unknown style silently, so a
// rejection here means one of the two is not a known decoration style.
plugins
.run_command(
ID,
"decorate",
Some(path.to_str().unwrap()),
Invocation { client: Some("main".into()), ..Invocation::default() },
)
.unwrap();
}
+19 -10
View File
@@ -139,7 +139,7 @@ async fn text_search_uses_overlay_excludes_utf16_and_limits() {
}
#[tokio::test(flavor = "multi_thread")]
async fn problems_share_project_reports_and_keep_sources_separate() {
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
let f = Harness::new();
let path = f.root.join("a.txt");
std::fs::write(&path, "text").unwrap();
@@ -153,21 +153,30 @@ async fn problems_share_project_reports_and_keep_sources_separate() {
deprecated: false,
server: None,
};
f.host.mcp_reports.report_problems(
"second",
true,
vec![ide_host::mcp::FileProblems {
// Producers publish per (file, producer) through the event sink, like the
// language servers and the spelling check do.
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.clone().into(),
items: vec![diagnostic("lsp"), diagnostic("plugin")],
}],
);
source: source.into(),
items,
});
};
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
publish(&f.host, "build", vec![diagnostic("plugin")]);
// A file outside the window's project is visible too: annotations follow the file.
let outside = f.host.diagnostics.problems().len();
let workspace = f.workspace();
let files = workspace.problems("main", Some(&path)).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
f.host.mcp_reports.forget_window("second");
assert!(workspace.problems("main", None).unwrap().is_empty());
assert_eq!(outside, 2);
// A full replacement per producer clears only that producer.
publish(&f.host, "rust-analyzer", vec![]);
let files = workspace.problems("main", None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].source, "build");
}
#[tokio::test(flavor = "multi_thread")]
+676 -77
View File
@@ -1,4 +1,13 @@
//! In-memory index: one slot per file, symbols stored with their file.
//! In-memory index: one slot per file, with compact query-time tables derived from it.
//!
//! Queries never walk the fat `FileEntry` structs: `HotFiles`, `HotDirs` and `SymTable` hold
//! only the fields a scan needs, packed in dense rows with interned strings, so a query
//! streams contiguous memory instead of chasing `Box<[Sym]>` / `Box<str>` pointers.
//! They are plain data, updated eagerly by `insert`/`remove` (every mutation needs `&mut
//! IndexData`), and all queries run under the `IndexData` read lock: a scan therefore sees
//! one consistent snapshot, and the only "invalidation" is the update itself. Space left
//! behind by replaced/removed entries is reclaimed by compaction inside `insert`/`remove`
//! once it dominates the table, and by `shrink`.
use ide_core::{Range, SymbolKind};
use serde::{Deserialize, Serialize};
@@ -33,6 +42,40 @@ pub(crate) fn mask_bit(b: u8) -> u64 {
}
}
/// A substring of an interned text arena.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Span {
pub start: u32,
pub len: u32,
}
impl Span {
/// A slice of nothing; used for optional fields (`None` containers).
pub(crate) const NONE: Span = Span { start: u32::MAX, len: 0 };
/// Appends `s` to `arena`, returning the span it will occupy.
///
/// Spans are `u32`-addressed and `NONE` uses `u32::MAX` as its start, so an
/// arena that reaches that bound cannot be represented. Fail loudly here
/// instead of truncating the offsets: a wrapped span would make [`Self::get`]
/// slice unrelated text, or panic far from the actual cause.
#[inline]
fn of(arena: &str, s: &str) -> Self {
let start = u32::try_from(arena.len()).expect("index text arena exceeded the 4 GiB span limit");
let len = u32::try_from(s.len()).expect("index text span exceeded the 4 GiB span limit");
assert!(
start != u32::MAX && start.checked_add(len).is_some(),
"index text arena exceeded the 4 GiB span limit"
);
Self { start, len }
}
#[inline]
pub(crate) fn get(self, arena: &str) -> &str {
&arena[self.start as usize..(self.start + self.len) as usize]
}
}
/// Persisted per-file record (postcard in redb, keyed by the relative path).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct FileRecord {
@@ -52,12 +95,12 @@ pub(crate) struct StoredSymbol {
pub range: Range,
}
pub(crate) struct Sym {
pub name: Box<str>,
/// A symbol resolved out of [`SymTable`].
pub(crate) struct SymRef<'a> {
pub name: &'a str,
pub kind: SymbolKind,
pub container: Option<Box<str>>,
pub container: Option<&'a str>,
pub range: Range,
pub mask: u64,
}
pub(crate) struct FileEntry {
@@ -73,38 +116,34 @@ pub(crate) struct FileEntry {
pub mtime: u64,
pub size: u64,
pub hash: [u8; 32],
pub symbols: Box<[Sym]>,
/// Range of this file's symbols in [`IndexData::syms`].
pub sym_start: u32,
pub sym_len: u32,
pub ignored: bool,
}
impl FileEntry {
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self {
/// Splits the record into the entry and its symbols; the symbols are flattened into
/// the index-wide [`SymTable`] by [`IndexData::insert`].
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> (Self, Vec<StoredSymbol>) {
let name_start = rel.rfind('/').map_or(0, |i| i + 1);
let name = &rel[name_start..];
let symbols = record
.symbols
.into_iter()
.map(|s| Sym {
mask: char_mask(&s.name),
name: s.name.into_boxed_str(),
kind: s.kind,
container: s.container.map(String::into_boxed_str),
range: s.range,
})
.collect();
Self {
let FileRecord { mtime, size, hash, symbols, ignored } = record;
let entry = Self {
name_start_chars: rel[..name_start].chars().count() as u32,
name_mask: char_mask(name),
rel_mask: char_mask(&rel),
name_start: name_start as u32,
path,
rel: rel.into_boxed_str(),
mtime: record.mtime,
size: record.size,
hash: record.hash,
symbols,
ignored: record.ignored,
}
mtime,
size,
hash,
sym_start: 0,
sym_len: 0,
ignored,
};
(entry, symbols)
}
pub(crate) fn name(&self) -> &str {
@@ -115,38 +154,391 @@ impl FileEntry {
&self.rel[..self.name_start as usize]
}
pub(crate) fn record(&self) -> FileRecord {
FileRecord {
mtime: self.mtime,
size: self.size,
hash: self.hash,
symbols: self
.symbols
.iter()
.map(|s| StoredSymbol {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
range: s.range,
})
.collect(),
ignored: self.ignored,
}
}
/// Whether the entry is out of date for `found` on disk.
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
}
}
/// The hot fields of one file slot, packed for the query scan; no pointers.
pub(crate) struct FileRow {
/// `rel` in [`HotFiles`]' arena.
pub rel: Span,
/// Byte offset of the file name inside `rel`.
pub name_start: u32,
pub sym_start: u32,
pub sym_len: u32,
/// Union of every symbol's [`char_mask`]: a selective symbol query can skip
/// the whole file before touching its symbol range.
pub sym_mask: u64,
pub rel_mask: u64,
pub name_mask: u64,
/// Mask of the directory part (`rel[..name_start]`), precomputed for path queries.
pub dir_mask: u64,
/// `rel` is pure ASCII, so nucleo can match it without a UTF-32 conversion.
pub ascii: bool,
/// The slot holds a file; free slots keep a row so scans line up with `IndexData::files`.
pub live: bool,
}
impl FileRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
#[inline]
pub(crate) fn name<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[self.name_start as usize..]
}
#[inline]
pub(crate) fn dir<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[..self.name_start as usize]
}
}
/// One dense row per file slot (same order and length as `IndexData::files`) plus the
/// text arena the rows point into.
#[derive(Default)]
pub(crate) struct HotFiles {
pub rows: Vec<FileRow>,
text: String,
dead_text: usize,
}
impl HotFiles {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
#[inline]
pub(crate) fn row(&self, slot: usize) -> Option<&FileRow> {
self.rows.get(slot).filter(|row| row.live)
}
#[inline]
pub(crate) fn row_of_slot(&self, slot: u32) -> Option<&FileRow> {
self.row(slot as usize)
}
/// `rel` of a live slot; candidate slots always are.
#[inline]
pub(crate) fn rel(&self, slot: u32) -> &str {
self.rows[slot as usize].rel(&self.text)
}
fn set(&mut self, slot: u32, row: FileRow) {
if self.rows.len() as u32 == slot {
self.rows.push(row);
} else {
self.rows[slot as usize] = row;
}
}
fn append(&mut self, rel: &str) -> Span {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
span
}
/// Updates the row of an existing slot (replace) in place.
fn update(&mut self, slot: u32, e: &FileEntry, sym_mask: u64) {
let idx = slot as usize;
if self.rows[idx].rel.get(&self.text) != &*e.rel {
self.dead_text += self.rows[idx].rel.len as usize;
let span = self.append(&e.rel);
self.rows[idx].rel = span;
self.rows[idx].ascii = e.rel.is_ascii();
self.rows[idx].dir_mask = char_mask(e.dir());
}
let row = &mut self.rows[idx];
row.name_start = e.name_start;
row.name_mask = e.name_mask;
row.rel_mask = e.rel_mask;
row.sym_start = e.sym_start;
row.sym_len = e.sym_len;
row.sym_mask = sym_mask;
row.live = true;
}
fn remove(&mut self, slot: u32) {
let row = &mut self.rows[slot as usize];
self.dead_text += row.rel.len as usize;
row.rel = Span::NONE;
row.sym_start = 0;
row.sym_len = 0;
row.sym_mask = 0;
row.live = false;
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead_text);
for row in &mut self.rows {
if !row.live {
continue;
}
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead_text = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// One folder holding indexed files, packed for the query scan.
pub(crate) struct DirRow {
/// `rel` in [`HotDirs`]' arena, including the trailing `/`.
pub rel: Span,
/// Byte offset of the last path segment inside the trimmed `rel`.
pub name_start: u32,
pub name_mask: u64,
/// Mask of `rel` without the trailing `/`.
pub rel_mask: u64,
pub path: PathBuf,
}
impl DirRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
}
#[derive(Default)]
pub(crate) struct HotDirs {
pub rows: Vec<DirRow>,
text: String,
dead: usize,
}
impl HotDirs {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
fn push(&mut self, rel: &str, abs: &Path) -> u32 {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
let trimmed = &rel[..rel.len() - 1];
let name_start = trimmed.rfind('/').map_or(0, |i| i + 1);
self.rows.push(DirRow {
rel: span,
name_start: name_start as u32,
name_mask: char_mask(&trimmed[name_start..]),
rel_mask: char_mask(trimmed),
path: abs.to_owned(),
});
(self.rows.len() - 1) as u32
}
fn remove(&mut self, row: u32, dirs: &mut HashMap<Box<str>, (PathBuf, u32, u32)>) {
let idx = row as usize;
let last = self.rows.len() - 1;
self.dead += self.rows[idx].rel.len as usize;
self.rows.swap_remove(idx);
if idx != last {
let moved = self.rows[idx].rel.get(&self.text);
if let Some(dir) = dirs.get_mut(moved) {
dir.2 = idx as u32;
}
}
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead);
for row in &mut self.rows {
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// The scan-relevant fields of one symbol: its character mask and the name in the
/// arena. Interleaved so a scan touches one cache line per candidate instead of three
/// parallel arrays.
#[derive(Clone, Copy)]
pub(crate) struct SymHead {
pub mask: u64,
pub name: Span,
}
/// Index-wide symbol storage: one row per symbol in parallel arrays, with names and
/// containers interned in a single arena. Each file points at a contiguous range.
#[derive(Default)]
pub(crate) struct SymTable {
head: Vec<SymHead>,
container: Vec<Span>,
kind: Vec<SymbolKind>,
range: Vec<Range>,
text: String,
dead_rows: usize,
}
impl SymTable {
#[inline]
pub(crate) fn name(&self, i: u32) -> &str {
self.head[i as usize].name.get(&self.text)
}
#[inline]
pub(crate) fn mask(&self, i: u32) -> u64 {
self.head[i as usize].mask
}
#[inline]
pub(crate) fn container(&self, i: u32) -> Option<&str> {
let span = self.container[i as usize];
(span.start != u32::MAX).then(|| span.get(&self.text))
}
/// The mask + name rows of a file's contiguous symbol range.
#[inline]
pub(crate) fn heads(&self, start: u32, len: u32) -> &[SymHead] {
let (start, end) = (start as usize, start as usize + len as usize);
&self.head[start..end]
}
#[inline]
pub(crate) fn kind(&self, i: u32) -> SymbolKind {
self.kind[i as usize]
}
#[inline]
pub(crate) fn range(&self, i: u32) -> Range {
self.range[i as usize]
}
fn intern(&mut self, s: &str) -> Span {
let span = Span::of(&self.text, s);
self.text.push_str(s);
span
}
/// Appends a file's symbols; they stay contiguous. Returns the start index
/// and the union of their masks.
fn push(&mut self, symbols: &[StoredSymbol]) -> (u32, u64) {
let start = self.head.len() as u32;
let mut agg_mask = 0u64;
for s in symbols {
let mask = char_mask(&s.name);
agg_mask |= mask;
let name = self.intern(&s.name);
let container = match &s.container {
Some(c) => self.intern(c),
None => Span::NONE,
};
self.head.push(SymHead { mask, name });
self.container.push(container);
self.kind.push(s.kind);
self.range.push(s.range);
}
(start, agg_mask)
}
/// Whether `symbols` would flatten to exactly the range at `start`.
fn same(&self, start: u32, len: u32, symbols: &[StoredSymbol]) -> bool {
len as usize == symbols.len()
&& symbols.iter().enumerate().all(|(k, s)| {
let i = start + k as u32;
self.name(i) == s.name
&& self.container(i) == s.container.as_deref()
&& self.kind(i) == s.kind
&& self.range(i) == s.range
})
}
fn free(&mut self, _start: u32, len: u32) {
self.dead_rows += len as usize;
}
/// Rewrites the live rows in file order, reclaiming the space of replaced files.
/// `files` entries keep their range in sync; queries read rows, but persistence
/// and `FileEntry` accessors go through the entry.
fn compact(&mut self, rows: &mut [FileRow], files: &mut [Option<FileEntry>]) {
let mut text = String::with_capacity(self.text.len());
let mut head = Vec::with_capacity(self.head.len() - self.dead_rows);
let mut container = Vec::with_capacity(head.capacity());
let mut kind = Vec::with_capacity(head.capacity());
let mut range = Vec::with_capacity(head.capacity());
let mut live = 0u32;
for (slot, row) in rows.iter_mut().enumerate() {
if !row.live {
row.sym_start = 0;
continue;
}
let (start, end) = (row.sym_start, row.sym_start + row.sym_len);
row.sym_start = live;
if let Some(e) = files.get_mut(slot).and_then(Option::as_mut) {
e.sym_start = live;
}
for i in start..end {
let name_span = self.head[i as usize].name;
let name = name_span.get(&self.text);
head.push(SymHead { mask: self.head[i as usize].mask, name: Span::of(&text, name) });
text.push_str(name);
let container_span = self.container[i as usize];
if container_span.start == u32::MAX {
container.push(Span::NONE);
} else {
let c = container_span.get(&self.text);
container.push(Span::of(&text, c));
text.push_str(c);
}
kind.push(self.kind[i as usize]);
range.push(self.range[i as usize]);
live += 1;
}
}
self.head = head;
self.container = container;
self.kind = kind;
self.range = range;
self.text = text;
self.dead_rows = 0;
}
fn shrink(&mut self) {
self.head.shrink_to_fit();
self.container.shrink_to_fit();
self.kind.shrink_to_fit();
self.range.shrink_to_fit();
self.text.shrink_to_fit();
}
}
#[derive(Default)]
pub(crate) struct IndexData {
pub files: Vec<Option<FileEntry>>,
by_path: HashMap<PathBuf, u32>,
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
/// trailing `/`: its absolute path and the number of files below it.
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
/// trailing `/`: its absolute path, the number of files below it, and its row in
/// [`HotDirs`].
pub dirs: HashMap<Box<str>, (PathBuf, u32, u32)>,
/// Compact query-time view of `files`, kept in sync by `insert`/`remove`.
pub hot: HotFiles,
/// Compact query-time view of `dirs`, kept in sync by `insert`/`remove`.
pub dirs_hot: HotDirs,
/// Flattened symbols of every file, kept in sync by `insert`/`remove`.
pub syms: SymTable,
free: Vec<u32>,
pub file_count: u32,
pub symbol_count: u32,
@@ -161,49 +553,124 @@ impl IndexData {
self.by_path.get(path).copied()
}
pub(crate) fn insert(&mut self, entry: FileEntry) {
let symbols = entry.symbols.len() as u32;
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
mtime: e.mtime,
size: e.size,
hash: e.hash,
symbols: self
.symbols_of(e)
.map(|s| StoredSymbol {
name: s.name.to_owned(),
kind: s.kind,
container: s.container.map(str::to_owned),
range: s.range,
})
.collect(),
ignored: e.ignored,
}
}
pub(crate) fn record_of(&self, path: &Path) -> Option<FileRecord> {
self.get(path).map(|e| self.record(e))
}
/// The symbols of one file, in declaration order.
pub(crate) fn symbols_of<'a>(&'a self, e: &'a FileEntry) -> impl Iterator<Item = SymRef<'a>> + 'a {
let syms = &self.syms;
(e.sym_start..e.sym_start + e.sym_len).map(move |i| SymRef {
name: syms.name(i),
kind: syms.kind(i),
container: syms.container(i),
range: syms.range(i),
})
}
pub(crate) fn insert(&mut self, mut entry: FileEntry, symbols: Vec<StoredSymbol>) {
let sym_len = symbols.len() as u32;
if let Some(&slot) = self.by_path.get(&entry.path) {
let old = self.files[slot as usize].replace(entry);
self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32);
self.symbol_count += symbols;
let old = self.files[slot as usize].as_ref().expect("indexed slots are live");
let old_len = old.sym_len;
// Rescans and saves that re-parse to the same symbols reuse the range; a
// same-path replacement then costs nothing in the symbol table.
let (sym_start, sym_mask) = if self.syms.same(old.sym_start, old_len, &symbols) {
(old.sym_start, self.hot.rows[slot as usize].sym_mask)
} else {
self.syms.free(old.sym_start, old_len);
self.syms.push(&symbols)
};
entry.sym_start = sym_start;
entry.sym_len = sym_len;
self.symbol_count = self.symbol_count - old_len + sym_len;
self.hot.update(slot, &entry, sym_mask);
self.files[slot as usize] = Some(entry);
self.compact_if_needed();
return;
}
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = sym_len;
let slot = match self.free.pop() {
Some(slot) => {
self.files[slot as usize] = Some(entry);
slot
}
Some(slot) => slot,
None => {
self.files.push(Some(entry));
self.files.push(None);
(self.files.len() - 1) as u32
}
};
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
let path = entry.path.clone();
let (name_start, name_mask, rel_mask) = (entry.name_start, entry.name_mask, entry.rel_mask);
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot,
FileRow {
rel,
name_start,
sym_start: entry.sym_start,
sym_len,
sym_mask,
rel_mask,
name_mask,
dir_mask: char_mask(&entry.rel[..name_start as usize]),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.files[slot as usize] = Some(entry);
if let Some(e) = &self.files[slot as usize] {
for (rel, abs) in ancestors(e) {
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
if let Some(dir) = self.dirs.get_mut(rel) {
dir.1 += 1;
} else {
let row = self.dirs_hot.push(rel, abs);
self.dirs.insert(rel.into(), (abs.to_owned(), 1, row));
}
}
}
self.by_path.insert(path, slot);
self.file_count += 1;
self.symbol_count += symbols;
self.symbol_count += sym_len;
self.compact_if_needed();
}
pub(crate) fn remove(&mut self, path: &Path) -> Option<FileEntry> {
let slot = self.by_path.remove(path)?;
let old = self.files[slot as usize].take()?;
self.free.push(slot);
self.hot.remove(slot);
self.syms.free(old.sym_start, old.sym_len);
for (rel, _) in ancestors(&old) {
if let Some(d) = self.dirs.get_mut(rel) {
d.1 -= 1;
if d.1 == 0 {
self.dirs.remove(rel);
}
let Some(dir) = self.dirs.get_mut(rel) else { continue };
dir.1 -= 1;
if dir.1 > 0 {
continue;
}
let Some((_, _, row)) = self.dirs.remove(rel) else { continue };
self.dirs_hot.remove(row, &mut self.dirs);
}
self.file_count -= 1;
self.symbol_count -= old.symbols.len() as u32;
self.symbol_count -= old.sym_len;
self.compact_if_needed();
Some(old)
}
@@ -213,6 +680,19 @@ impl IndexData {
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
}
/// Reclaims the space of replaced/removed files once it dominates a table.
fn compact_if_needed(&mut self) {
if self.hot.dead_text > 1 << 16 && self.hot.dead_text * 2 > self.hot.text.len() {
self.hot.compact();
}
if self.syms.dead_rows > 4096 && self.syms.dead_rows * 2 > self.syms.head.len() {
self.syms.compact(&mut self.hot.rows, &mut self.files);
}
if self.dirs_hot.dead > 1 << 16 && self.dirs_hot.dead * 2 > self.dirs_hot.text.len() {
self.dirs_hot.compact();
}
}
/// Releases spare capacity (after large deletions).
pub(crate) fn shrink(&mut self) {
while matches!(self.files.last(), Some(None)) {
@@ -221,6 +701,11 @@ impl IndexData {
self.free.retain(|&s| s != last);
}
self.files.shrink_to_fit();
self.hot.rows.truncate(self.files.len());
self.hot.shrink();
self.dirs_hot.shrink();
self.syms.compact(&mut self.hot.rows, &mut self.files);
self.syms.shrink();
self.free.shrink_to_fit();
self.by_path.shrink_to_fit();
self.dirs.shrink_to_fit();
@@ -248,7 +733,7 @@ fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
mod tests {
use super::*;
fn entry(path: &str, symbols: usize) -> FileEntry {
fn entry(path: &str, symbols: usize) -> (FileEntry, Vec<StoredSymbol>) {
let record = FileRecord {
symbols: (0..symbols)
.map(|i| StoredSymbol {
@@ -263,48 +748,101 @@ mod tests {
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
}
fn insert(d: &mut IndexData, path: &str, symbols: usize) {
let (e, s) = entry(path, symbols);
d.insert(e, s);
}
/// The compact tables must always mirror the entries they were derived from.
fn assert_consistent(d: &IndexData) {
assert_eq!(d.hot.rows.len(), d.files.len(), "one hot row per slot");
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), d.file_count as usize, "live rows are files");
for (slot, e) in d.files.iter().enumerate() {
let Some(e) = e else {
assert!(d.hot.row(slot).is_none(), "free slot {slot} has no row");
continue;
};
let row = d.hot.row(slot).unwrap_or_else(|| panic!("slot {slot} has a row"));
assert_eq!(row.rel(d.hot.text()), &*e.rel);
assert_eq!(row.name(d.hot.text()), e.name());
assert_eq!(row.dir(d.hot.text()), e.dir());
assert_eq!(row.name_mask, char_mask(e.name()));
assert_eq!(row.rel_mask, char_mask(&e.rel));
assert_eq!(row.dir_mask, char_mask(e.dir()));
assert_eq!(row.ascii, e.rel.is_ascii());
assert_eq!(row.sym_start, e.sym_start);
assert_eq!(row.sym_len, e.sym_len);
assert_eq!(row.sym_mask, d.symbols_of(e).fold(0u64, |m, s| m | char_mask(s.name)));
assert_eq!(d.syms.head.len(), d.syms.container.len());
assert_eq!(d.syms.head.len(), d.syms.kind.len());
assert_eq!(d.syms.head.len(), d.syms.range.len());
assert!(e.sym_len == 0 || (e.sym_start as usize) < d.syms.head.len());
assert!(e.sym_start + e.sym_len <= d.syms.head.len() as u32);
}
for (rel, (path, count, row)) in &d.dirs {
let row = &d.dirs_hot.rows[*row as usize];
assert_eq!(row.rel(d.dirs_hot.text()), &**rel);
assert_eq!(&row.path, path);
assert!(*count > 0);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
assert_eq!(row.name_mask, char_mask(name));
assert_eq!(row.rel_mask, char_mask(trimmed));
}
assert_eq!(d.dirs.len(), d.dirs_hot.rows.len());
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
d.insert(entry("a/b/c.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("top.rs", 0));
insert(&mut d, "a/b/c.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "top.rs", 0);
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
dirs.sort();
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
assert_consistent(&d);
d.remove(Path::new("/r/a/b/c.rs"));
assert!(!d.dirs.contains_key("a/b/"));
assert_consistent(&d);
d.remove_tree(Path::new("/r/a"));
assert!(d.dirs.is_empty());
assert_consistent(&d);
}
#[test]
fn insert_replace_remove_keep_counts() {
let mut d = IndexData::default();
d.insert(entry("a/b.rs", 2));
d.insert(entry("c.rs", 1));
insert(&mut d, "a/b.rs", 2);
insert(&mut d, "c.rs", 1);
assert_eq!((d.file_count, d.symbol_count), (2, 3));
d.insert(entry("a/b.rs", 5));
insert(&mut d, "a/b.rs", 5);
assert_eq!((d.file_count, d.symbol_count), (2, 6));
assert_consistent(&d);
assert!(d.remove(Path::new("/r/c.rs")).is_some());
assert_eq!((d.file_count, d.symbol_count), (1, 5));
d.insert(entry("d.rs", 0));
assert_consistent(&d);
insert(&mut d, "d.rs", 0);
assert_eq!(d.files.len(), 2, "slot reused");
assert_consistent(&d);
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
assert_eq!((d.file_count, d.symbol_count), (1, 0));
let e = d.get(Path::new("/r/d.rs")).unwrap();
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
d.insert(entry("e.rs", 1));
d.insert(entry("f.rs", 1));
insert(&mut d, "e.rs", 1);
insert(&mut d, "f.rs", 1);
d.remove(Path::new("/r/e.rs"));
d.remove(Path::new("/r/f.rs"));
d.shrink();
assert_consistent(&d);
assert_eq!(d.files.len(), 2, "trailing free slot dropped");
assert_eq!(d.free, vec![0]);
d.insert(entry("g.rs", 1));
insert(&mut d, "g.rs", 1);
assert_eq!((d.file_count, d.files.len()), (2, 2));
assert_consistent(&d);
}
#[test]
@@ -312,4 +850,65 @@ mod tests {
assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab"));
assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z"));
}
fn insert_symbols(d: &mut IndexData, path: &str, symbols: Vec<StoredSymbol>) {
let record = FileRecord { symbols, ..Default::default() };
let (e, s) = FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record);
d.insert(e, s);
}
fn symbol(n: &str) -> StoredSymbol {
StoredSymbol { name: n.to_owned(), kind: SymbolKind::Function, container: None, range: Range::default() }
}
#[test]
fn compaction_keeps_entries_and_records_in_sync() {
let mut d = IndexData::default();
insert(&mut d, "a.rs", 3);
insert(&mut d, "b/c.rs", 2);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
d.shrink();
assert_consistent(&d);
assert_eq!(d.syms.head.len() as u32, d.symbol_count, "compaction drops dead symbol rows");
let names = |d: &IndexData, p: &str| -> Vec<String> {
let e = d.get(Path::new(p)).unwrap();
d.record(e).symbols.into_iter().map(|s| s.name).collect()
};
assert_eq!(names(&d, "/r/a.rs"), ["fresh"]);
assert_eq!(names(&d, "/r/b/c.rs"), ["s0", "s1"]);
assert_eq!(d.symbol_count, 3);
}
#[test]
fn large_replacements_reclaim_symbol_storage() {
let mut d = IndexData::default();
insert_symbols(&mut d, "a.rs", (0..5_000).map(|i| symbol(&format!("old{i}"))).collect());
assert_eq!(d.syms.head.len(), 5_000);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
assert_eq!(d.syms.head.len(), 1, "dead symbol rows are reclaimed");
assert_eq!((d.symbol_count, d.file_count), (1, 1));
assert_consistent(&d);
}
#[test]
fn tree_removal_leaves_no_hot_rows_or_dirs() {
let mut d = IndexData::default();
insert(&mut d, "a/b/one.rs", 2);
insert(&mut d, "a/b/two.rs", 3);
insert(&mut d, "a/three.rs", 1);
insert(&mut d, "elsewhere/four.rs", 1);
assert_eq!(d.remove_tree(Path::new("/r/a")).len(), 3);
assert_consistent(&d);
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), 1);
assert!(d.hot.row(0).is_none(), "removed slots have no live row");
assert_eq!(d.dirs.len(), 1, "elsewhere/ only");
assert_eq!(d.dirs_hot.rows.len(), 1);
assert_eq!(d.symbol_count, 1);
d.shrink();
assert_eq!(d.syms.head.len() as u32, d.symbol_count);
// Slots are reusable and the new row replaces the tombstone.
insert(&mut d, "a/five.rs", 2);
assert_consistent(&d);
assert_eq!(d.file_count, 2);
}
}
+9 -17
View File
@@ -199,12 +199,11 @@ impl ProjectIndex {
return None;
}
Some(
file.symbols
.iter()
data.symbols_of(file)
.map(|s| ide_language::SymbolTag {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_ref().map(ToString::to_string),
container: s.container.map(ToString::to_string),
range: s.range,
})
.collect(),
@@ -231,7 +230,7 @@ impl ProjectIndex {
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
matching::find_files(&d, &matching::Recents::new(&recent), query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
@@ -274,7 +273,7 @@ impl ProjectIndex {
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
matching::find_symbols(&d, &matching::Recents::new(&recent), query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
@@ -303,15 +302,7 @@ impl ProjectIndex {
break data;
}
};
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
matching::find_symbols_until(&data, &matching::Recents::new(&recent), query, filter, limit, include, stopped)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
@@ -523,7 +514,8 @@ pub mod bench_support {
})
.collect();
let record = FileRecord { symbols, ..Default::default() };
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
let (entry, symbols) = FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record);
data.insert(entry, symbols);
}
let recent = data.paths().take(MAX_RECENT).cloned().collect();
Self { data, recent }
@@ -538,7 +530,7 @@ pub mod bench_support {
}
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
matching::find_files(&self.data, &matching::Recents::new(&self.recent), query, limit)
}
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
@@ -546,7 +538,7 @@ pub mod bench_support {
}
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
matching::find_symbols(&self.data, &matching::Recents::new(&self.recent), query, filter, limit)
}
}
}
+160 -108
View File
@@ -4,14 +4,19 @@
//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best
//! few; the survivors then get match indices, CamelHump and recency boosts and
//! are re-sorted.
//!
//! The scan streams the compact tables in `data` (`HotFiles`/`HotDirs`/`SymTable`):
//! only the hot fields per candidate and strings from contiguous arenas, with the
//! thread-local matcher and one UTF-32 buffer per scan rather than per candidate.
use crate::data::{FileEntry, IndexData, char_mask};
use crate::data::{DirRow, FileEntry, FileRow, IndexData, SymTable, char_mask};
use crate::{FileMatch, SymbolFilter, SymbolMatch};
use ide_core::SymbolKind;
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const TIER_NAME: u32 = 1 << 20;
@@ -54,6 +59,11 @@ impl Needle {
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
}
/// `hay` is pure ASCII: skip `Utf32Str::new`'s check and UTF-32 conversion.
fn score_ascii(&self, m: &mut Matcher, hay: &str) -> Option<u32> {
m.fuzzy_match(Utf32Str::Ascii(hay.as_bytes()), self.utf32.slice(..)).map(u32::from)
}
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
out.clear();
let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from);
@@ -105,6 +115,9 @@ struct Cand {
slot: u32,
sym: u32,
score: u32,
/// Candidate name/rel length: the first tie-break key, cached so ranking a pool
/// does not chase it through the tables.
len: u32,
}
fn keep_best(mut v: Vec<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
@@ -119,7 +132,7 @@ fn scan(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
@@ -129,18 +142,22 @@ fn scan_until(
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
data.files
// One row per slot, so this is exactly the old scan over `files`: same item
// order and count, and free slots are skipped exactly like `None` was, keeping
// the pruning boundaries (and therefore the candidate pool) unchanged.
data.hot
.rows
.par_iter()
.enumerate()
.with_min_len(256)
.try_fold(Vec::new, |mut acc, (slot, e)| {
.try_fold(Vec::new, |mut acc, (slot, row)| {
if stopped() {
return None;
}
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if row.live {
with_matcher(|m| per_file(slot as u32, row, m, &mut acc));
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
@@ -155,15 +172,22 @@ fn scan_until(
pub(crate) struct Recents<'a> {
pub paths: &'a [PathBuf],
/// Position of each path, so ranking a survivor does not scan the list.
/// Duplicates keep their first position, like `position`.
ranks: HashMap<&'a Path, usize>,
}
impl Recents<'_> {
fn rank(&self, path: &std::path::Path) -> Option<usize> {
self.paths.iter().position(|p| p == path)
impl<'a> Recents<'a> {
pub(crate) fn new(paths: &'a [PathBuf]) -> Self {
let mut ranks = HashMap::with_capacity(paths.len());
for (i, p) in paths.iter().enumerate() {
ranks.entry(p.as_path()).or_insert(i);
}
Self { paths, ranks }
}
fn bonus(&self, path: &std::path::Path) -> u32 {
self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12))
fn bonus(&self, path: &Path) -> u32 {
self.ranks.get(path).map_or(0, |&r| RECENT_MAX.saturating_sub(r as u32 * 12))
}
}
@@ -209,29 +233,31 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) },
None => FileQuery::Name(Needle::new(&q)),
};
let tiebreak = |a: &Cand, b: &Cand| {
let (ea, eb) = (file(data, a.slot), file(data, b.slot));
ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel))
};
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let pool = limit * 4 + 64;
let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| {
let mut cands = scan(data, pool, &tiebreak, |slot, row, m, acc| {
let mut buf = Vec::new();
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
acc.push(Cand { slot, sym: u32::MAX, score });
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
acc.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
}
});
// Recent files always compete for the final ranking.
for p in recents.paths {
if let Some(e) = data.get(p) {
with_matcher(|m| {
let mut buf = Vec::new();
for p in recents.paths {
if data.get(p).is_none() {
continue;
}
let Some(slot) = data.slot(p) else { continue };
if !cands.iter().any(|c| c.slot == slot) {
let mut buf = Vec::new();
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
cands.push(Cand { slot, sym: u32::MAX, score });
}
if cands.iter().any(|c| c.slot == slot) {
continue;
}
let Some(row) = data.hot.row_of_slot(slot) else { continue };
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
cands.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
}
}
}
});
let mut out: Vec<(u32, &FileEntry, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
@@ -273,30 +299,46 @@ pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<File
}
let by_path = q.contains('/');
let needle = Needle::new(q.trim_end_matches('/'));
let text = data.dirs_hot.text();
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
let trimmed = &rel[..rel.len() - 1];
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
let name = &trimmed[start..];
if !by_path
&& needle.mask & char_mask(name) == needle.mask
&& let Some(s) = needle.score(m, name, buf)
{
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
}
if needle.mask & char_mask(trimmed) != needle.mask {
return None;
}
needle.score(m, trimmed, buf).map(|s| (s, None))
};
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
.dirs
let (mut best, _): (Vec<_>, Vec<char>) = data
.dirs_hot
.rows
.par_iter()
.filter_map(|(rel, (path, _))| {
let mut buf = Vec::new();
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
})
.collect();
.fold(
|| (Vec::new(), Vec::new()),
|(mut found, mut buf), row: &DirRow| {
let rel = row.rel(text);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
with_matcher(|m| {
let hit = if !by_path
&& needle.mask & row.name_mask == needle.mask
&& let Some(s) = needle.score(m, name, &mut buf)
{
Some((
TIER_NAME + s * 4 + needle.name_boost(name),
Some(trimmed[..row.name_start as usize].chars().count() as u32),
))
} else if needle.mask & row.rel_mask != needle.mask {
None
} else {
needle.score(m, trimmed, &mut buf).map(|s| (s, None))
};
if let Some((s, at)) = hit {
found.push((s, rel, row.path.as_path(), at));
}
});
(found, buf)
},
)
.reduce(
|| (Vec::new(), Vec::new()),
|mut a, b| {
a.0.extend(b.0);
a
},
);
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
};
@@ -338,33 +380,39 @@ fn file(data: &IndexData, slot: u32) -> &FileEntry {
data.files[slot as usize].as_ref().expect("candidate slots are live")
}
fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec<char>) -> Option<u32> {
fn base_file_score(m: &mut Matcher, q: &FileQuery, row: &FileRow, text: &str, buf: &mut Vec<char>) -> Option<u32> {
// Strings are sliced only once their mask passes, so a rejected row costs two masks.
match q {
FileQuery::Name(n) => {
if n.mask & e.name_mask == n.mask
&& let Some(s) = n.score(m, e.name(), buf)
{
return Some(TIER_NAME + s * 4 + n.name_boost(e.name()));
if n.mask & row.name_mask == n.mask {
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) };
if let Some(s) = s {
return Some(TIER_NAME + s * 4 + n.name_boost(name));
}
}
if n.mask & e.rel_mask == n.mask {
return n.score(m, &e.rel, buf);
if n.mask & row.rel_mask == n.mask {
let rel = row.rel(text);
return if row.ascii { n.score_ascii(m, rel) } else { n.score(m, rel, buf) };
}
None
}
FileQuery::Path { dir, name } => {
let name_score = if name.is_empty() {
FileQuery::Path { dir, name: n } => {
let name_score = if n.is_empty() {
0
} else {
if name.mask & e.name_mask != name.mask {
if n.mask & row.name_mask != n.mask {
return None;
}
name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name())
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) }?;
s * 4 + n.name_boost(name)
};
let dir_str = e.dir();
if dir.mask & char_mask(dir_str) != dir.mask {
if dir.mask & row.dir_mask != dir.mask {
return None;
}
let dir_score = dir.score(m, dir_str, buf)?;
let dir_str = row.dir(text);
let dir_score = if row.ascii { dir.score_ascii(m, dir_str) } else { dir.score(m, dir_str, buf) }?;
Some(TIER_NAME + name_score + dir_score)
}
}
@@ -396,22 +444,21 @@ fn symbol_score(
m: &mut Matcher,
name: &Needle,
container: Option<&Needle>,
s: &crate::data::Sym,
syms: &SymTable,
i: u32,
buf: &mut Vec<char>,
) -> Option<u32> {
if name.mask & s.mask != name.mask {
return None;
}
let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 };
score += name.name_boost(&s.name);
let symbol = syms.name(i);
let mut score = if name.is_empty() { 0 } else { name.score(m, symbol, buf)? * 4 };
score += name.name_boost(symbol);
if let Some(cq) = container {
let c = s.container.as_deref()?;
let c = syms.container(i)?;
if cq.mask & char_mask(c) != cq.mask {
return None;
}
score += cq.score(m, c, buf)? + cq.name_boost(c) / 4;
}
if s.kind.is_type_like() {
if syms.kind(i).is_type_like() {
score += TYPE_LIKE;
}
Some(score)
@@ -455,30 +502,35 @@ pub(crate) fn find_symbols_until(
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
let container = container_q.map(Needle::new);
let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize];
let tiebreak = |a: &Cand, b: &Cand| {
let (sa, sb) = (sym(a), sym(b));
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let syms = &data.syms;
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let pool = limit * 4 + 64;
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, row, m, acc| {
// Whole-file pre-filter: when the union of the file's symbol masks
// already misses a query character, no symbol can match.
if row.sym_len == 0 || name.mask & row.sym_mask != name.mask {
return;
}
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) {
acc.push(Cand { slot, sym: i as u32, score });
}
let e = file(data, slot);
if !include(&e.path) {
return;
}
let mut buf = Vec::new();
for (k, head) in syms.heads(row.sym_start, row.sym_len).iter().enumerate() {
if stopped() {
break;
}
});
if filter == SymbolFilter::Types && !syms.kind(row.sym_start + k as u32).is_type_like() {
continue;
}
if name.mask & head.mask != name.mask {
continue;
}
let i = row.sym_start + k as u32;
if let Some(score) = symbol_score(m, &name, container.as_ref(), syms, i, &mut buf) {
acc.push(Cand { slot, sym: i, score, len: head.name.len });
}
}
})?;
if stopped() {
return None;
@@ -489,33 +541,32 @@ pub(crate) fn find_symbols_until(
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let s = sym(&c);
let symbol = syms.name(c.sym);
let e = file(data, c.slot);
let mut idx = Vec::new();
if !name.is_empty() {
name.indices(m, &s.name, &mut buf, &mut idx)?;
name.indices(m, symbol, &mut buf, &mut idx)?;
}
let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4;
let bonus = hump_bonus(symbol, &idx) + recents.bonus(&e.path) / 4;
Some((c.score + bonus, c, idx))
})
.collect()
});
out.sort_by(|a, b| {
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| syms.range(a.1.sym).cmp(&syms.range(b.1.sym)))
});
out.truncate(limit);
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
SymbolMatch {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
name: syms.name(c.sym).to_string(),
kind: syms.kind(c.sym),
container: syms.container(c.sym).map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: s.range,
range: syms.range(c.sym),
score,
indices,
}
@@ -541,24 +592,25 @@ pub(crate) fn symbols_named_within(
) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
let syms = &data.syms;
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.filter(|e| within(e))
.flat_map(|e| {
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
(e.sym_start..e.sym_start + e.sym_len)
.filter(|&i| syms.mask(i) == mask && syms.name(i) == name && kind.is_none_or(|k| syms.kind(i) == k))
.map(move |i| SymbolMatch {
name: syms.name(i).to_string(),
kind: syms.kind(i),
container: syms.container(i).map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: s.range,
range: syms.range(i),
score: 0,
indices: Vec::new(),
},
)
})
})
.collect();
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
+7 -8
View File
@@ -173,7 +173,7 @@ impl Shared {
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<FileEntry> = records
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
@@ -182,8 +182,8 @@ impl Shared {
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for e in entries {
d.insert(e);
for (e, symbols) in entries {
d.insert(e, symbols);
}
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
@@ -321,8 +321,7 @@ impl Shared {
match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let d = self.data.read();
let mut rec = d.get(&f.path)?.record();
let mut rec = self.data.read().record_of(&f.path)?;
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
@@ -338,11 +337,11 @@ impl Shared {
let puts: Vec<(String, FileRecord)> =
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
{
let entries: Vec<FileEntry> =
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
let mut d = self.data.write();
for e in entries {
d.insert(e);
for (e, symbols) in entries {
d.insert(e, symbols);
}
}
self.persist(&puts, &[], false);
+2
View File
@@ -79,6 +79,7 @@ const BUILTIN: &[(&str, &str)] = &[
("scala", include_str!("../../../languages/scala.toml")),
("scss", include_str!("../../../languages/scss.toml")),
("shellscript", include_str!("../../../languages/shellscript.toml")),
("slint", include_str!("../../../languages/slint.toml")),
("sql", include_str!("../../../languages/sql.toml")),
("svelte", include_str!("../../../languages/svelte.toml")),
("swift", include_str!("../../../languages/swift.toml")),
@@ -1046,6 +1047,7 @@ mod tests {
("icon.svg.import", "godot-resource"),
("export_presets.cfg", "godot-resource"),
("water.gdshader", "gdshader"),
("app-window.slint", "slint"),
("notes.txt", "plaintext"),
];
for (path, id) in cases {
+41 -2
View File
@@ -445,8 +445,12 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
// Starting the same build again can't help: only an update can.
let expired = self.reported_expired_build();
let delay = if expired { None } else { self.record_crash() };
message.push_str(if expired {
"\nThis build has expired; update it to start it again."
} else if delay.is_some() {
"\nRestarting…"
} else {
"\nCrashed repeatedly; restart it manually."
@@ -757,6 +761,12 @@ impl Server {
crash_excerpt(&lines, n)
}
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
/// exit with "This build of kotlin-server has expired".
fn reported_expired_build(&self) -> bool {
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
@@ -911,6 +921,10 @@ fn is_error_headline(line: &str) -> bool {
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
}
fn is_expired_build_line(line: &str) -> bool {
line.contains("This build of ") && line.contains(" has expired")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1071,6 +1085,31 @@ mod tests {
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
}
#[test]
fn an_expired_build_is_not_restarted() {
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
s.push_stderr(
generation,
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
);
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
assert_eq!(s.status(), ServerStatus::Crashed);
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
assert!(matches!(s.state.lock().conn, Conn::Failed));
// A manual restart (after an update) starts it again.
s.reset_for_restart();
assert!(s.begin_start().is_some());
// Any other crash is restarted.
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "Exception in thread main".into());
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
}
#[test]
fn a_superseded_start_is_not_announced() {
let s = server();
+4 -1
View File
@@ -55,7 +55,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.7";
pub const API_VERSION: &str = "1.8";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -1058,6 +1058,9 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
}),
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
WorkbenchEvent::ProblemsChanged => {
wit_events::Event::Other(wit_types::Extension { kind: "problems-changed".into(), data: "{}".into() })
}
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
action: a.action,
+8 -1
View File
@@ -119,11 +119,13 @@ pub enum EventKind {
EditorFocused,
ActionPerformed,
VcsChanged,
/// Problems of the window's files changed (a language server or a report).
ProblemsChanged,
Scheduled,
}
impl EventKind {
pub const ALL: [Self; 16] = [
pub const ALL: [Self; 17] = [
Self::DocumentChanged,
Self::WebsocketReady,
Self::FileClosed,
@@ -139,6 +141,7 @@ impl EventKind {
Self::EditorFocused,
Self::ActionPerformed,
Self::VcsChanged,
Self::ProblemsChanged,
Self::Scheduled,
];
@@ -159,6 +162,7 @@ impl EventKind {
Self::EditorFocused => "editor-focused",
Self::ActionPerformed => "action-performed",
Self::VcsChanged => "vcs-changed",
Self::ProblemsChanged => "problems-changed",
Self::Scheduled => "scheduled",
}
}
@@ -294,6 +298,8 @@ pub enum WorkbenchEvent {
EditorFocused(Option<String>),
ActionPerformed(ActionPerformed),
VcsChanged(String),
/// Problems of the window's files changed; plugins re-read `problems`.
ProblemsChanged,
Scheduled,
}
@@ -315,6 +321,7 @@ impl WorkbenchEvent {
Self::EditorFocused(_) => EventKind::EditorFocused,
Self::ActionPerformed(_) => EventKind::ActionPerformed,
Self::VcsChanged(_) => EventKind::VcsChanged,
Self::ProblemsChanged => EventKind::ProblemsChanged,
Self::Scheduled => EventKind::Scheduled,
}
}
+5 -5
View File
@@ -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.7.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
let host = v("1.8.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
assert!(newer.contains("1.9") && 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.7 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.8 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());
Binary file not shown.
@@ -1 +1 @@
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
Binary file not shown.
@@ -1 +1 @@
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
Binary file not shown.
@@ -1 +1 @@
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
Binary file not shown.
@@ -1 +1 @@
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
Binary file not shown.
@@ -1 +1 @@
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
+25
View File
@@ -21,6 +21,31 @@ impl Plugin for Fixture {
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
)
.map(|()| None),
// The host-owned snapshot, one entry per producer: what `problems` reads.
"problems" => {
let files: Vec<String> = host::problems(None)?
.into_iter()
.map(|f| format!("{}:{}:{}", f.path, f.source, f.diagnostics.len()))
.collect();
Ok(Some(files.join("|")))
}
// A line-end annotation in the severity styles the editor draws: the host must
// keep `typo` and `weak` instead of dropping them as unknown.
"decorate" => {
let path = arg.unwrap_or_default();
let item = |style: &str, after: &str| openidea_plugin::Decoration {
range: Range::default(),
style: style.into(),
hover: None,
after: Some(after.into()),
};
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
}
Binary file not shown.
@@ -1 +1 @@
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
Binary file not shown.
@@ -1 +1 @@
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
Binary file not shown.
@@ -1 +1 @@
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
Binary file not shown.
@@ -1 +1 @@
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
Binary file not shown.
@@ -1 +1 @@
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
Binary file not shown.
@@ -1 +1 @@
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
Binary file not shown.
@@ -1 +1 @@
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
Binary file not shown.
@@ -1 +1 @@
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
Binary file not shown.
@@ -1 +1 @@
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
Binary file not shown.
@@ -1 +1 @@
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
Binary file not shown.
@@ -1 +1 @@
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
@@ -1 +1 @@
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
Binary file not shown.
@@ -1 +1 @@
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
+3 -3
View File
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.7.0;
export openidea:plugin/providers@1.7.0;
export openidea:plugin/file-actions@1.7.0;
include openidea:plugin/plugin-syntax@1.8.0;
export openidea:plugin/providers@1.8.0;
export openidea:plugin/file-actions@1.8.0;
}
",
path: "../../../../../wit",
Binary file not shown.
@@ -1 +1 @@
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
Binary file not shown.
@@ -1 +1 @@
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
Binary file not shown.
@@ -1 +1 @@
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
Binary file not shown.
@@ -1 +1 @@
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
Binary file not shown.
@@ -1 +1 @@
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
Binary file not shown.
@@ -1 +1 @@
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
Binary file not shown.
@@ -1 +1 @@
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
Binary file not shown.
@@ -1 +1 @@
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
Binary file not shown.
@@ -1 +1 @@
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
Binary file not shown.
@@ -1 +1 @@
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
444497128ec5e1e01229a93d2340b49ccc5bc27e67defb2c4fe19683042442a3
+1
View File
@@ -249,6 +249,7 @@ mod tests {
("buf", "buf-Windows-arm64.zip"),
("texlab", "texlab-aarch64-windows.zip"),
("neocmakelsp", "neocmakelsp-aarch64-pc-windows-msvc.zip"),
("slint-lsp", "slint-lsp-aarch64-pc-windows-msvc.zip"),
] {
assert_eq!(
select_asset(&[asset], crate::registry::find(tool).unwrap().asset_rules(), platform),
+21
View File
@@ -892,6 +892,27 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/latex-lsp/texlab",
needs: &[],
},
ToolSpec {
name: "slint-lsp",
display: "Slint LSP",
kind: LanguageServer,
commands: &["slint-lsp"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "slint-ui/slint" },
assets: &[
any_arch(Mac, "slint-lsp-universal-apple-darwin.tar.gz"),
rule(Linux, X64, "slint-lsp-x86_64-unknown-linux-gnu.tar.gz"),
rule(Linux, Arm64, "slint-lsp-aarch64-unknown-linux-gnu.tar.gz"),
rule(Windows, X64, "slint-lsp-x86_64-pc-windows-msvc.zip"),
rule(Windows, Arm64, "slint-lsp-aarch64-pc-windows-msvc.zip"),
],
locate: &["slint-lsp"],
},
fallback: None,
version: ver("slint-lsp"),
homepage: "https://slint.dev",
needs: &[],
},
ToolSpec {
name: "neocmakelsp",
display: "neocmakelsp (CMake)",
+1 -1
View File
@@ -48,7 +48,7 @@ openidea-server --stdio | --connect <host> <port> | --version
### Installing it on the remote machine
The desktop picks the server binary for the remote OS and architecture (from `uname -sm`): `OPENIDEA_SERVER_BINARY` if set, then `<data dir>/server/<os>-<arch>/openidea-server`, then the one next to the app when the platforms match; otherwise it downloads the release build for that platform once into its data dir. It uploads the binary into `~/.openidea/server/` on the remote host, one folder per version, keyed by a content hash, and removes other builds of that version. CI keeps a static Linux (musl) build working.
The desktop picks the server binary for the remote OS and architecture (from `uname -sm`): `OPENIDEA_SERVER_BINARY` if set, then the one next to the app when the platforms match; otherwise the release build of this version for that platform, downloaded once into `<data dir>/server/<version>/<os>-<arch>/`. Offline, it falls back to a build of this version already on the host, then to the unversioned `<data dir>/server/<os>-<arch>/openidea-server`. The server reports its version in `initialize`; a command missing on a server of another version fails with both versions named. It uploads the binary into `~/.openidea/server/` on the remote host, one folder per version, keyed by a content hash, and removes other builds of that version. CI keeps a static Linux (musl) build working.
## The protocol
+9 -6
View File
@@ -66,15 +66,18 @@ When the desktop machine can't reach GitHub, give it the server binary yourself.
this order:
1. The file named by the `OPENIDEA_SERVER_BINARY` environment variable, if it is set when OpenIDEA starts.
2. `server/<os>-<arch>/openidea-server` in the desktop's [local data directory](/en/reference/data-directories), for
example `server/linux-x86_64/openidea-server`.
3. An `openidea-server` next to the OpenIDEA executable, when the host has the same platform as the desktop.
2. An `openidea-server` next to the OpenIDEA executable, when the host has the same platform as the desktop.
3. `server/<version>/<os>-<arch>/openidea-server` in the desktop's
[local data directory](/en/reference/data-directories), for example `server/0.1.44/linux-x86_64/openidea-server`.
This is also where the desktop keeps the server it downloads, so a file put there is used instead of downloading.
Download `openidea-server-<os>-<arch>.gz` from the release that matches your OpenIDEA version, unpack it with
`gunzip`, and put it in one of these places. Use the same version as the desktop app; the desktop refuses a server
that speaks a different protocol.
`gunzip`, and put it in the version's folder. After an update the desktop looks in the new version's folder, so an
older server is never used by mistake.
If the host already has a build of the current version installed, the desktop uses it when nothing can be downloaded.
If nothing can be downloaded, the desktop uses a build of the current version already installed on the host, and then
`server/<os>-<arch>/openidea-server` (the place earlier versions used), which may be another version: commands added
since then report that the server does not match the app.
### Build from source
+1
View File
@@ -24,6 +24,7 @@ The same page also has:
- **Accent color**: the UI's accent color.
- **Different tool window background**: set tool windows apart from the editor.
- **Background Image**: an image shown through the project window, with its **Opacity**, **Fill** (scale, original size or tile) and **Anchor**.
- **Accessibility**: **Use contrast scrollbars** and **Adjust colors for red-green vision deficiency** (for protanopia and deuteranopia).
- **Compact mode** under **UI Options**.
+1
View File
@@ -163,6 +163,7 @@ The table lists every built-in language definition. Server and debugger names in
| Scala | `scala` | `.scala`, `.sc`, `.sbt`, `.mill` | Yes | Yes | metals | — |
| SCSS | `scss` | `.scss` | Yes | — | **vscode-css-language-server** | — |
| Shell Script | `shellscript` | `.sh`, `.bash`, `.zsh`, `.ksh`, `.ash`, … | Yes | Yes | **bash-language-server** | **bash-debug** |
| Slint | `slint` | `.slint` | Yes | — | **slint-lsp** | — |
| SQL | `sql` | `.sql`, `.ddl`, `.dml`, `.psql`, `.pgsql`, … | Yes | — | **sqls** | — |
| Svelte | `svelte` | `.svelte` | Yes | — | **svelte-language-server** + **oxlint** | — |
| Swift | `swift` | `.swift`, `.swiftinterface` | Yes | Yes | sourcekit-lsp | lldb-dap |
+3 -2
View File
@@ -3,7 +3,7 @@ title: "API versions and compatibility"
description: "engines.openidea, the plugin API versions from 1.0 to 1.6, and the rules that keep released plugins loading in newer IDEs."
---
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.7.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.8.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
<Note>
The plugin system is a preview. The compatibility rules below hold for released 1.x versions; what is still unreleased
@@ -21,7 +21,8 @@ The plugin API has one version number: the version of the WIT package in [`wit/`
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`, `ipc:`, `ide.commands` and `database.sources` capabilities; editor and project snapshots, decorations, text search, Problems, Git log/blame/diff; output channels; OAuth loopback; runs, terminal sessions, run configurations, test result parsing and run targets; formatting and other language features; embedded languages; snippets; more menu locations; database sources; MCP tools |
| 1.5.0 | OpenIDEA 0.1.41 | `Project::notify` (a notification in one project's window), the `net:ask` capability, run target validation, per-command run target working-directory arguments |
| 1.6.0 | OpenIDEA 0.1.43 | Bounded, project-bound background WebSockets for event-stream plugins |
| 1.7.0 | In development on `main` | `views.open-diff`: two texts in the diff viewer, such as a review's file before and after |
| 1.7.0 | OpenIDEA 0.1.44 | `views.open-diff`: two texts in the diff viewer, such as a review's file before and after |
| 1.8.0 | In development on `main` | The `problems-changed` workbench event: the host keeps every producer's diagnostics per file, so an annotation follows the file rather than the invoking window |
The full list of what each version added is in [`plugins/README.md`](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md).
+1
View File
@@ -109,6 +109,7 @@ Settings › Appearance & Behavior › Appearance, unless noted.
| `uiFontFallback` | `""` | **UI fallback font**. |
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
| `backgroundImage` | `opacity: 15` | **Background Image**: `path` (empty: none), `opacity` (0–100), `fill` (`scale` / `plain` / `tile`), `anchor` (`center`, `top-left`, …). |
| `compactMode` | `false` | **Compact mode**. |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
+1 -1
View File
@@ -48,7 +48,7 @@ openidea-server --stdio | --connect <host> <port> | --version
### 安装到远程机器
桌面端根据远程的操作系统和架构(来自 `uname -sm`)选择服务端二进制:先看是否设置了 `OPENIDEA_SERVER_BINARY`,再看 `<data dir>/server/<os>-<arch>/openidea-server`,平台相同时再用应用旁边自带的那个;都没有就把该平台的 release 构建下载到数据目录(只下载一次)。之后把二进制上传到远程主机的 `~/.openidea/server/`,每个版本一个目录,按内容哈希区分,并删除同版本的其他构建。CI 会持续验证静态 Linux(musl)构建。
桌面端根据远程的操作系统和架构(来自 `uname -sm`)选择服务端二进制:先看是否设置了 `OPENIDEA_SERVER_BINARY`,平台相同时再用应用旁边自带的那个;否则使用该平台当前版本的 release 构建,只下载一次,放在 `<data dir>/server/<版本>/<os>-<arch>/`。离线时先用主机上已装的当前版本构建,再用不带版本的 `<data dir>/server/<os>-<arch>/openidea-server`。服务端在 `initialize` 中回报版本;版本不同的服务端缺少某个命令时,错误会写明两边的版本。之后把二进制上传到远程主机的 `~/.openidea/server/`,每个版本一个目录,按内容哈希区分,并删除同版本的其他构建。CI 会持续验证静态 Linux(musl)构建。
## 协议
+7 -5
View File
@@ -57,13 +57,15 @@ SSH 执行的命令默认只有很精简的环境,所以服务端启动时会
本机访问不了 GitHub 时,可以自己提供服务端二进制。桌面端按以下顺序查找:
1. 环境变量 `OPENIDEA_SERVER_BINARY` 指向的文件(需在启动 OpenIDEA 时已设置)。
2. 本机[本地数据目录](/zh/reference/data-directories)中的 `server/<os>-<arch>/openidea-server`,例如 `server/linux-x86_64/openidea-server`。
3. 主机与本机平台相同时,OpenIDEA 可执行文件旁边的 `openidea-server`。
2. 主机与本机平台相同时,OpenIDEA 可执行文件旁边的 `openidea-server`。
3. 本机[本地数据目录](/zh/reference/data-directories)中的 `server/<版本>/<os>-<arch>/openidea-server`,例如
`server/0.1.44/linux-x86_64/openidea-server`。桌面端下载的服务端也放在这里,所以放在这里的文件会代替下载。
从与你的 OpenIDEA 版本对应的 Release 下载 `openidea-server-<os>-<arch>.gz`,用 `gunzip` 解压后放到上述任一位置。版本必须与桌面端一致:
协议版本不同的服务端会被拒绝。
从与你的 OpenIDEA 版本对应的 Release 下载 `openidea-server-<os>-<arch>.gz`,用 `gunzip` 解压后放到该版本的目录。
升级后桌面端会到新版本的目录查找,不会误用旧版服务端。
如果主机上已经装有当前版本的某个构建,在无法下载时桌面端会直接使用它。
无法下载时,桌面端先用主机上已装的当前版本构建,再用 `server/<os>-<arch>/openidea-server`(早期版本使用的位置),
它可能是其他版本:之后新增的命令会提示服务端与应用版本不一致。
### 从源码构建
+1
View File
@@ -24,6 +24,7 @@ description: "选择界面主题和编辑器配色方案,导入 IntelliJ 主
- **强调色**:界面的强调色。
- **不同的工具窗口背景**:让工具窗口与编辑器区分开。
- **背景图像**:透过项目窗口显示的图像,可设置 **不透明度**、**填充**(缩放、原始大小或平铺)和 **位置**。
- **辅助功能**:**使用对比度滚动条** 和 **针对红绿色觉缺陷调整颜色**(适用于红色盲和绿色盲)。
- **UI 选项** 下的 **紧凑模式**。
+1
View File
@@ -158,6 +158,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| Scala | `scala` | `.scala`, `.sc`, `.sbt`, `.mill` | 是 | 是 | metals | — |
| SCSS | `scss` | `.scss` | 是 | — | **vscode-css-language-server** | — |
| Shell Script | `shellscript` | `.sh`, `.bash`, `.zsh`, `.ksh`, `.ash`, … | 是 | 是 | **bash-language-server** | **bash-debug** |
| Slint | `slint` | `.slint` | 是 | — | **slint-lsp** | — |
| SQL | `sql` | `.sql`, `.ddl`, `.dml`, `.psql`, `.pgsql`, … | 是 | — | **sqls** | — |
| Svelte | `svelte` | `.svelte` | 是 | — | **svelte-language-server** + **oxlint** | — |
| Swift | `swift` | `.swift`, `.swiftinterface` | 是 | 是 | sourcekit-lsp | lldb-dap |
+3 -2
View File
@@ -3,7 +3,7 @@ title: "API 版本与兼容"
description: "engines.openidea、插件 API 从 1.0 到 1.6 的各个版本,以及让已发布插件在新 IDE 中继续加载的规则。"
---
插件 API 只有一个版本号:[`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中 WIT 包的版本(`main` 上是 `openidea:plugin@1.7.0`)。宿主的引擎版本和 SDK 的 `API_VERSION` 始终与它一致,SDK 里也带着一份完全相同的 WIT 文件。
插件 API 只有一个版本号:[`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) 中 WIT 包的版本(`main` 上是 `openidea:plugin@1.8.0`)。宿主的引擎版本和 SDK 的 `API_VERSION` 始终与它一致,SDK 里也带着一份完全相同的 WIT 文件。
<Note>插件系统目前是预览版。下面的兼容规则适用于已发布的 1.x 版本;`main` 上尚未发布的内容在发布前仍可能变化。</Note>
@@ -18,7 +18,8 @@ description: "engines.openidea、插件 API 从 1.0 到 1.6 的各个版本,
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`、`ipc:`、`ide.commands` 和 `database.sources` 能力;编辑器与项目快照、装饰、文本搜索、问题视图、Git log/blame/diff;输出通道;OAuth 回环;运行、终端会话、运行配置、测试结果解析和运行目标;格式化等语言功能;嵌入语言;代码片段;更多菜单位置;数据库数据源;MCP 工具 |
| 1.5.0 | OpenIDEA 0.1.41 | `Project::notify`(只在某个项目的窗口中显示通知)、`net:ask` 能力、运行目标启动前校验、运行目标按命令设置工作目录参数 |
| 1.6.0 | OpenIDEA 0.1.43 | 面向事件流插件、受容量限制且绑定项目的后台 WebSocket |
| 1.7.0 | `main` 上开发中 | `views.open-diff`:在差异查看器中对比两段文本,例如评审中某个文件的修改前后 |
| 1.7.0 | OpenIDEA 0.1.44 | `views.open-diff`:在差异查看器中对比两段文本,例如评审中某个文件的修改前后 |
| 1.8.0 | `main` 上开发中 | `problems-changed` 工作台事件:宿主按文件保存每个生产者的诊断,注解跟着文件走,而不是跟着调用它的窗口 |
每个版本新增内容的完整列表见 [`plugins/README.md`](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md)。
+1
View File
@@ -109,6 +109,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| `uiFontFallback` | `""` | **UI 备用字体**。 |
| `contrastScrollbars` | `false` | **使用对比度滚动条**。 |
| `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 |
| `backgroundImage` | `opacity: 15` | **背景图像**:`path`(空为不显示)、`opacity`(0–100)、`fill`(`scale` / `plain` / `tile`)、`anchor`(`center`、`top-left` 等)。 |
| `compactMode` | `false` | **紧凑模式**。 |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 |
| `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 |
+43 -14
View File
@@ -71,7 +71,14 @@ async function openReply(page: Page, text: string, imageData?: string, theme?: "
[
task.id,
[
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
{
type: "user",
text: "Show Markdown",
attachments: [],
sentMs: 0,
doneMs: 1,
steered: false,
},
{ type: "agent", text },
],
],
@@ -111,7 +118,11 @@ test("scrolls a wide table inside the reply", async ({ page }) => {
const reply = await openReply(page, REPLY);
const sizes = await reply.evaluate((el) => {
const table = el.querySelector("table")!;
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
return {
reply: el.clientWidth,
table: table.clientWidth,
content: table.scrollWidth,
};
});
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
expect(sizes.content).toBeGreaterThan(sizes.table);
@@ -148,7 +159,10 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
let fetched = 0;
await page.route("https://images.example.com/code.gif", (route) => {
fetched++;
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
});
const reply = await openReply(
page,
@@ -158,7 +172,9 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
const width = (name: string) =>
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
await expect.poll(() => width("Local QR")).toBe(1);
const load = reply.getByRole("button", { name: "Load image from images.example.com" });
const load = reply.getByRole("button", {
name: "Load image from images.example.com",
});
await expect(load).toBeVisible();
expect(fetched).toBe(0);
await load.click();
@@ -203,12 +219,10 @@ test("keeps latest reply actions visible and quotes into the draft without shift
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
const draft = page.getByRole("textbox", { name: "Message", exact: true });
await expect(draft).toBeFocused();
await expect(draft).toHaveValue(
REPLY.trimEnd()
.split("\n")
.map((line) => (line ? `> ${line}` : ">"))
.join("\n") + "\n\n",
);
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
const quoted = REPLY.trimEnd().split("\n");
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
});
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
@@ -231,7 +245,14 @@ test("keeps chat history readable and selections free of timestamps and status l
await page.evaluate(async (task) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
const user = (text: string) => ({
type: "user",
text,
attachments: [],
sentMs: 1,
doneMs: 5001,
steered: false,
});
store.useAgents.setState({
entries: new Map([
[
@@ -326,7 +347,9 @@ test("keeps hover actions reachable when the pointer leaves the message body", a
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
await reply.hover();
const box = await copy.boundingBox();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
steps: 20,
});
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
await copy.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
@@ -468,7 +491,10 @@ for (const theme of ["dark", "light"] as const) {
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) };
return {
text: el.textContent,
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
};
});
});
expect(contrasts).toHaveLength(4);
@@ -480,7 +506,10 @@ test("draws a diagram without reaching the hosts its configuration names", async
const asked: string[] = [];
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
asked.push(new URL(route.request().url()).pathname);
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
});
const at = (name: string) => `https://images.example.com/${name}`;
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
+77
View File
@@ -0,0 +1,77 @@
/**
* Git Branches keeps the groups the user expanded or collapsed after a restart, as IDEA does.
* Git is stubbed at the IPC layer; reloading the page stands in for restarting the IDE.
*/
import type { Page } from "@playwright/test";
import { expect, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
async function openBranches(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
const h = window.__ipc.handlers;
const branch = (name: string, remote: boolean, current = false) => ({
name,
current,
remote,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: null,
commit: "1111111",
});
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [];
h.vcs_tags = () => [];
h.vcs_branches = () => [
branch("main", false, true),
branch("develop", false),
branch("origin/main", true),
branch("origin/release", true),
];
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
await loadBranches();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("vcs.branches");
}, ROOT);
const popup = page.getByRole("dialog", { name: "Git Branches" });
await expect(popup.getByRole("listbox", { name: "Branches" })).toBeVisible();
return popup;
}
const group = (popup: ReturnType<Page["getByRole"]>, label: string) =>
popup.locator("[aria-expanded]").filter({ has: popup.page().getByText(label, { exact: true }) });
test("Git Branches reopens with the groups expanded as the user left them, also after a restart", async ({
workbench: page,
}) => {
let popup = await openBranches(page);
await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "false");
await group(popup, "Remote").click();
await group(popup, "Local").click();
await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "true");
await expect(group(popup, "Local")).toHaveAttribute("aria-expanded", "false");
await expect(popup.getByText("release", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await page.reload();
await expect(page.getByRole("tree", { name: "Project" })).toBeVisible();
popup = await openBranches(page);
await expect(group(popup, "Remote")).toHaveAttribute("aria-expanded", "true");
await expect(group(popup, "Local")).toHaveAttribute("aria-expanded", "false");
await expect(popup.getByText("release", { exact: true })).toBeVisible();
await expect(popup.getByText("develop", { exact: true })).toBeHidden();
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Git Log toolbar buttons close their popup when clicked again, as in IDEA, instead of
* keeping it open (Branch) or replaying its opening (the menus). Git is stubbed at the IPC layer.
*/
import { expect, stubDesktop, test } from "./fixtures";
test.beforeEach(async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const root = "/dev/acme-api";
const id = (i: number) => i.toString(16).padStart(40, "0");
const commits = Array.from({ length: 20 }, (_, i) => ({
id: id(i),
shortId: id(i).slice(-8),
parents: i + 1 < 20 ? [id(i + 1)] : [],
refs: i === 0 ? ["HEAD", "main"] : [],
author: "Author",
email: "[email protected]",
timeMs: 20 - i,
summary: `Commit ${i}`,
message: `Commit ${i}`,
}));
const h = window.__ipc.handlers;
h.vcs_log = ({ query }) => {
const { skip, limit, range } = query as { skip: number; limit: number; range?: string };
return range ? [] : commits.slice(skip, limit ? skip + limit : undefined);
};
h.vcs_log_refs = () => ({});
h.vcs_log_users = () => ["Author <[email protected]>"];
h.vcs_branches = () => [];
h.vcs_branch_info = () => ({
head: "main",
headCommit: id(0),
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_config_get = () => null;
h.vcs_commit_files = () => [];
h.vcs_log_details_batch = ({ revisions }) =>
(revisions as string[]).map(() => ({
message: "",
containingBranches: [],
signature: "N",
signer: "",
key: "",
fingerprint: "",
}));
const storeModule = "/src/features/vcs/store.ts";
const { useVcs } = await import(/* @vite-ignore */ storeModule);
useVcs.setState({ available: true, root, repo: root, roots: [root], branchesLoaded: true });
const contextModule = "/src/platform/context.ts";
const { setContext } = await import(/* @vite-ignore */ contextModule);
setContext("vcsAvailable", true);
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("vcs.showLog");
});
await expect(page.getByRole("listbox", { name: "Commits" }).getByRole("option").first()).toBeVisible();
});
test("a second click on a Git Log toolbar button closes its popup", async ({ workbench: page }) => {
const toolbar = page.getByRole("toolbar", { name: "Log filters" });
const branchPopup = page.getByRole("dialog", { name: "Filter by Branch" });
// Opened on HEAD: "Branch: HEAD".
const branch = toolbar.getByRole("button", { name: /^Branch/ });
await branch.click();
await expect(branchPopup).toBeVisible();
await branch.click();
await expect(branchPopup).toBeHidden();
// The menus: User, Date, the filters that don't fit (Paths), then the toolbar's own dropdowns.
for (const [name, item] of [
["User", "Author <[email protected]>"],
["Date", "Last 7 Days"],
["More Filters", "Paths"],
["Graph Options", "By Commit Date"],
["View Options", "Tag Names"],
] as const) {
const button = toolbar.getByRole("button", { name, exact: true });
const row = page.getByRole("menuitem", { name: item }).or(page.getByRole("menuitemcheckbox", { name: item }));
await button.click();
await expect(row).toBeVisible();
await button.click();
await expect(row).toBeHidden();
// Still closed a moment later: the click did not open it again.
await page.waitForTimeout(300);
await expect(row).toBeHidden();
// And a third click opens it again.
await button.click();
await expect(row).toBeVisible();
await page.keyboard.press("Escape");
await expect(row).toBeHidden();
}
});
+14
View File
@@ -0,0 +1,14 @@
id = "slint"
name = "Slint"
extensions = ["slint"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
# C-like: the C mode colours comments, strings, numbers and common keywords until
# the server's semantic tokens arrive.
ui-mode = "C"
[[language-servers]]
name = "slint-lsp"
command = "slint-lsp"
install-hint = "cargo install slint-lsp (https://github.com/slint-ui/slint/releases)"
+15 -2
View File
@@ -36,7 +36,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.7.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.8.0`, world
`plugin`).
## 1. Quick start
@@ -418,9 +418,22 @@ are always available. Calling a gated function without its capability returns
## 3. Writing code (Rust SDK)
### API 1.8
API 1.8.0 is in development on `main`. Use `engines.openidea = "^1.8"` and the
matching SDK. Released 1.7 plugins keep working. New in 1.8:
- `problems-changed` (activation `on-event:problems-changed`, event
`Event::ProblemsChanged`): the host records every `Diagnostics` event it
emits — language servers, builds, spelling and plugin providers — per file
and producer, and `diagnostics.problems` reads that snapshot, so a plugin's
annotations follow the file instead of the window that published them.
`set_decorations` accepts files outside the open project for the same reason,
and the `typo` / `weak` styles are no longer dropped.
### API 1.7
API 1.7.0 is in development on `main`. Use `engines.openidea = "^1.7"` and the
API 1.7.0 shipped with OpenIDEA 0.1.44. Use `engines.openidea = "^1.7"` and the
matching SDK. Released 1.6 plugins keep working. New in 1.7:
- `host::open_diff(&ContentDiff { .. }, file_name)` (`views.open-diff`) opens
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
Binary file not shown.
@@ -1 +1 @@
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
78465a96e1e34ef3d4cf658c3b80f8388e43604dfe5abbf105f1149d040c5994
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more