Compare commits

..
Author SHA1 Message Date
l0ng-ai de12bf1189 chore(plugins): rebuild components for set-file-note 2026-11-13 14:15:46 +08:00
l0ng-ai 4c71891924 feat(plugins): notes after file names in the project tree (API 1.7)
Tree decorations could only carry a badge of at most two characters and a
tooltip, so a plugin could not label files and folders with a visible
description.

decorations.set-file-note shows one line of muted text (at most 200
characters, optional tooltip) after a file or folder name, as the project
tree already does for a root's VCS branch. It replaces that tree set's
item for the file, shares the tree-set limits, and is cleared with
clear-file. The SDK wraps it as host::set_file_note with a mock.
2026-11-13 14:08:19 +08:00
l0ng-ai 6143011880 fix(http-client): take the server port from the active Spring profiles' files (#3079)
Generated requests read server.port and the context path only from application.properties / application.yml, so a port in application-dev.yml under spring.profiles.active=dev was ignored. The active profiles' files are now read over the base ones, the last profile winning.
2026-11-13 13:55:41 +08:00
l0ng-ai d1872c07ce feat(http-client): request bodies from DTOs in other modules, header completion (#3078)
The @RequestBody type of a Spring mapping was looked up only beside the controller or in its source root, so a DTO in another Maven module produced {}. Types not found there are looked up through the project index in the imported (or same / wildcard-imported) package, and superclass fields are included. Header names and common header values complete in .http request blocks. Requests opened from an endpoint use the module's server address instead of a fixed localhost:8080.
2026-11-13 13:54:32 +08:00
l0ng-ai cd79293fd7 fix(libraries): keep External Libraries in a stable order and reload after a build sync (#3077)
External Libraries groups were listed in detection order, which differed between the streamed and final results; they are now sorted SDKs first, then libraries by name. A Maven sync that changed dependency versions without touching a manifest left old versions listed; every successful build sync now emits projectRootsChanged (Gradle already did) and External Libraries reloads on it.
2026-11-13 13:48:12 +08:00
rohitkumardandaandrohitdanda 86f7b747a5 fix(security): stop other pages from embedding the app's own pages (#2998)
The app's CSP now includes `frame-ancestors 'none'`, so no page (for example a project's HTML in the built-in preview) can embed the IDE's own pages in a frame. Notebook HTML output, plugin views (srcdoc frames) and the built-in HTML preview still render in a debug bundle that serves the CSP.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 13:46:25 +08:00
l0ng-ai 2e5e9acd9f fix(ui): keep Select All in a reading pane out of the composer (#3073)
⌘A in an agent conversation selected the pane from its own start to its own end; WebKit paints a selection ending on the pane's boundary up to the next selectable text after it, so the composer looked selected too. Select All now runs from the pane's first to its last selectable text, like a clamped drag selection already does.
2026-11-13 13:43:14 +08:00
caojcm b5e9e27dad fix(updater): update portable Windows installs in place (#2846)
A portable Windows build (the raw exe from the portable ZIP, which has no bundle type) updated through the installer, which installed the app elsewhere instead of updating the folder it was unpacked in.

- Updater: an unbundled Windows build asks for the `windows-<arch>-portable` target. Its update is the signed portable ZIP (verified by the updater's download), unpacked into a staging folder next to the exe; only `openidea.exe` and `WebView2Loader.dll` are accepted. Each file is replaced with a backup, rolled back if any replacement fails, and the backups are removed on the next start.
- Release: the portable ZIP is signed with the updater key and `latest.json` lists `windows-x86_64-portable` and `windows-aarch64-portable`.

Co-authored-by: caojcm <[email protected]>
2026-11-13 13:34:56 +08:00
GuoYuandguoyucode ff1bfeea9d feat(mcp): check_files for fresh diagnostics, exact read_file ranges, search results with the matched line and context (#3061)
- check_files syncs each file from disk, tells its language servers it was saved (so on-save checks such as rust-analyzer's cargo check run), waits until every server answered for the current text and went idle, and reports per file whether the problems are complete (checked / incomplete / serverUnavailable / noLanguageServer / untrusted / failed) with each server's status and reason.
- read_file with offset or limit returns exactly that range plus where to read on; the default read of a whole file keeps its head and tail.
- search_text / search_regex / search_symbol results carry the matched line (long lines cut around the match) and, with `context`, the lines before and after it.
- Merged main: check_files uses the run tools' uncapped timeout.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:29:43 +08:00
89 changed files with 1534 additions and 133 deletions

No files matched your search

+9
View File
@@ -151,6 +151,13 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -233,6 +240,8 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
];
const platforms = {};
for (const sig of sigs) {
+176
View File
@@ -0,0 +1,176 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
fn problem(d: Diagnostic, text: &str) -> Value {
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+34 -9
View File
@@ -30,9 +30,14 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file.
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
The maximum accepted limit is 5000 lines.",
|call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
@@ -50,7 +55,7 @@ The maximum accepted limit is 5000 lines.",
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -70,10 +75,12 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
@@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String>
if available <= max_lines {
return Ok(range(start, count));
}
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
@@ -245,7 +261,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
render_slice(text, start, max, false).unwrap()
}
#[test]
@@ -254,18 +270,27 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_long_slices() {
fn keeps_head_and_tail_of_a_long_default_read() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+2
View File
@@ -1,4 +1,5 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
@@ -31,6 +32,7 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
+1 -1
View File
@@ -25,7 +25,7 @@ const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
+131 -2
View File
@@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -49,6 +57,8 @@ fn search_text() -> Tool {
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -57,6 +67,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -66,6 +77,8 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
@@ -75,6 +88,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -84,6 +98,7 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
@@ -99,6 +114,7 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -145,6 +161,13 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
impl SearchItem {
@@ -182,7 +205,9 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
},
"required": ["filePath"],
} },
@@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q)
}
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -416,6 +522,7 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
@@ -494,7 +601,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
let mut items: Vec<SearchItem> = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
@@ -726,6 +836,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -869,6 +987,17 @@ mod tests {
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
+2 -1
View File
@@ -313,10 +313,11 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
+30 -2
View File
@@ -4125,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4143,7 +4148,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4315,10 +4320,33 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+29
View File
@@ -112,6 +112,16 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -454,6 +464,25 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
}
#[tokio::test(flavor = "multi_thread")]
async fn check_files_says_why_a_file_could_not_be_checked() {
let h = Harness::new();
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
h.trust().await;
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
assert_eq!(files["files"][0]["problems"], json!([]));
assert_eq!(files["files"][1]["status"], "failed", "{files}");
assert!(files["files"][1]["error"].is_string());
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
let none = h.error("check_files", json!({"filePaths": []})).await;
assert!(none.contains("at least one file"), "{none}");
}
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
/// agent tab's configuration (shell from the default) and says whether it started. Returns
/// the configurations it started, for reruns.
+116
View File
@@ -22,6 +22,9 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
/// syntax first, semantics later).
const CHECK_SETTLE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug)]
pub struct InspectOptions {
@@ -48,6 +51,29 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId);
}
/// One language server's part in a [`DocumentCheck`].
#[derive(Clone, Debug)]
pub struct CheckedServer {
pub name: String,
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
pub status: ide_core::event::ServerStatus,
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
pub message: Option<String>,
/// Its diagnostics are in the result.
pub checked: bool,
/// It was still analysing (or had not published for this change) when the wait ended.
pub incomplete: bool,
/// Asking it failed.
pub error: Option<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// What became of one file.
#[derive(Clone, Debug)]
pub enum FileInspection {
@@ -198,6 +224,96 @@ impl LspManager {
}
}
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
/// servers the document was saved, since checks that run on save (rust-analyzer's
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
/// to be ready, to answer for this text and to finish the background work it started, up
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
/// problems" from "nothing could check".
pub async fn check_document(
&self,
doc: DocId,
changed: Option<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
// Once the servers have the document: a save sent while one starts would not reach it.
self.did_save(doc, snap.text.as_str());
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
let mut report = CheckedServer {
name: server.name.clone(),
status: server.status(),
message: server.status_message(),
checked: false,
incomplete: false,
error: None,
};
let Some(run) = run else { return Some((report, Vec::new())) };
let t = Target { server: server.clone(), run };
if !t.caps().restriction.diagnostics() {
return None;
}
let pull = t.caps().has("diagnosticProvider");
let mut since = changed;
let raw = loop {
// A pull asks for this text; a push server is waited on to publish for it.
let raw = if pull {
server.wait_idle(left(), cancel).await;
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
} else {
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
wait_for_publish(server, &snap.path, since, opts, cancel).await
};
// The save may have started work (a `cargo check`) that publishes when it ends.
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
break raw;
}
if !server.wait_idle(left(), cancel).await {
break raw.map(|_| server.diagnostics_for(&snap.path));
}
// Idle: its last publish is final once quiet.
since = None;
};
report.status = server.status();
match raw {
Ok(items) => {
report.checked = true;
let fresh =
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
report.incomplete = !fresh || server.is_busy();
let conv = self.conv(t.enc());
let items = items
.into_iter()
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
.collect::<Vec<_>>();
Some((report, items))
}
Err(e) => {
report.error = Some(e.message);
Some((report, Vec::new()))
}
}
});
let results = tokio::select! {
r = futures::future::join_all(per_server) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let mut out = DocumentCheck::default();
for (report, items) in results.into_iter().flatten() {
out.servers.push(report);
out.diagnostics.extend(items);
}
Ok(out)
}
async fn pull_diagnostics(
&self,
t: &Target,
+1 -1
View File
@@ -40,7 +40,7 @@ mod vue_navigation;
mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
+18
View File
@@ -658,6 +658,24 @@ impl Server {
}
}
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
/// cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.phase_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`;
+51
View File
@@ -2178,6 +2178,57 @@ async fn missing_server_reports_once() {
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
let (server, uri) = (s.clone(), uri_of(&path));
let publisher = tokio::spawn(async move {
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
"severity": 1, "message": "on save" });
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
});
let cancel = CancellationToken::new();
let changed = Some(std::time::Instant::now());
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
publisher.await.unwrap();
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
// A change the server never answered: what it has is reported as not fresh.
let changed = Some(std::time::Instant::now());
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_reports_servers_that_cannot_check() {
let cancel = CancellationToken::new();
let h = harness(&[("ghost", json!({}))]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(check.diagnostics.is_empty());
let s = &check.servers[0];
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
assert_eq!(s.message.as_deref(), Some("install ghost"));
let h = harness(&[("mock", incremental_caps())]);
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Misconfigured).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
assert!(!check.servers[0].checked);
}
#[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]);
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+12 -1
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2507,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
@@ -1 +1 @@
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
@@ -1 +1 @@
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
@@ -1 +1 @@
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
@@ -1 +1 @@
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
@@ -1 +1 @@
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
@@ -1 +1 @@
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
@@ -1 +1 @@
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -1 +1 @@
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
@@ -1 +1 @@
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
@@ -1 +1 @@
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
@@ -1 +1 @@
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
@@ -1 +1 @@
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
@@ -1 +1 @@
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
@@ -1 +1 @@
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
@@ -1 +1 @@
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
@@ -1 +1 @@
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
@@ -1 +1 @@
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
@@ -1 +1 @@
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
@@ -1 +1 @@
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
@@ -1 +1 @@
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
@@ -1 +1 @@
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
@@ -1 +1 @@
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
@@ -1 +1 @@
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
@@ -1 +1 @@
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
@@ -1 +1 @@
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
@@ -1 +1 @@
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
@@ -1 +1 @@
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
-2
View File
@@ -19,8 +19,6 @@ tar = "0.4"
zip.workspace = true
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dev-dependencies]
+2
View File
@@ -20,6 +20,8 @@ mod bundled;
pub mod formatter;
pub mod jdk;
pub mod platform;
#[cfg(any(windows, test))]
pub mod portable_update;
mod process;
pub mod registry;
pub mod server;
+223
View File
@@ -0,0 +1,223 @@
//! In-place updates of the Windows unpack-and-run distribution.
use ide_core::{IdeError, IdeResult};
use std::fs;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
/// `bytes` must already have passed the updater's signature verification.
pub fn install(exe: &Path, bytes: &[u8]) -> IdeResult<()> {
let directory = exe.parent().ok_or_else(|| IdeError::invalid("executable has no parent directory"))?;
let staging = tempfile::Builder::new().prefix(".openidea-update-").tempdir_in(directory)?;
let files = unpack(bytes, staging.path(), exe)?;
let mut replaced = Vec::new();
for (source, target) in files {
match replace_file(&source, &target) {
Ok(backup) => replaced.push((target, backup)),
Err(error) => {
for (target, backup) in replaced.into_iter().rev() {
fs::remove_file(&target)?;
if let Some(backup) = backup {
fs::rename(&backup, &target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
}
return Err(error);
}
}
}
Ok(())
}
/// Old executables stay locked until their process exits; retry cleanup on the next start.
pub fn cleanup(exe: &Path) {
for target in [exe.to_path_buf(), exe.with_file_name("WebView2Loader.dll")] {
let _ = fs::remove_file(backup_path(&target));
}
}
fn unpack(bytes: &[u8], staging: &Path, exe: &Path) -> IdeResult<Vec<(PathBuf, PathBuf)>> {
let invalid = |error| IdeError::invalid(format!("portable update archive: {error}"));
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut files = Vec::new();
let mut has_exe = false;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(invalid)?;
let name = entry.name().map_err(invalid)?;
let target = match name.as_ref() {
"openidea.exe" => {
has_exe = true;
exe.to_path_buf()
}
"WebView2Loader.dll" => exe.with_file_name("WebView2Loader.dll"),
name => return Err(IdeError::invalid(format!("unexpected portable update file: {name}"))),
};
if !entry.is_file() || entry.is_symlink() || files.iter().any(|(_, path)| path == &target) {
return Err(IdeError::invalid("portable update contains a duplicate or non-regular file"));
}
let source = staging.join(name.as_ref());
let mut file = fs::File::create(&source)?;
io::copy(&mut entry, &mut file)?;
file.sync_all()?;
files.push((source, target));
}
if !has_exe {
return Err(IdeError::invalid("portable update is missing openidea.exe"));
}
Ok(files)
}
fn replace_file(source: &Path, target: &Path) -> IdeResult<Option<PathBuf>> {
let backup = if target.try_exists()? {
if !target.is_file() {
return Err(IdeError::invalid(format!("not an application file: {}", target.display())));
}
let backup = backup_path(target);
if backup.try_exists()? {
fs::remove_file(&backup)?;
}
// Windows forbids overwriting a running exe, but allows renaming it.
fs::rename(target, &backup)?;
Some(backup)
} else {
None
};
if let Err(error) = fs::rename(source, target) {
if let Some(backup) = &backup {
fs::rename(backup, target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
return Err(error.into());
}
Ok(backup)
}
fn backup_path(target: &Path) -> PathBuf {
let mut path = target.as_os_str().to_os_string();
path.push(".openidea-old");
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn package(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn replaces_application_files_and_preserves_configuration() {
for name in ["openidea.exe", "我的 IDE.exe"] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join(name);
fs::write(&exe, b"old exe").unwrap();
fs::write(tmp.path().join("WebView2Loader.dll"), b"old dll").unwrap();
fs::write(tmp.path().join("settings.toml"), b"my settings").unwrap();
fs::create_dir(tmp.path().join("data")).unwrap();
fs::write(tmp.path().join("data/plugins.json"), b"my plugins").unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
install(&exe, &bytes).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(tmp.path().join("WebView2Loader.dll")).unwrap(), b"new dll");
assert_eq!(fs::read(tmp.path().join("settings.toml")).unwrap(), b"my settings");
assert_eq!(fs::read(tmp.path().join("data/plugins.json")).unwrap(), b"my plugins");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
assert!(!backup_path(&tmp.path().join("WebView2Loader.dll")).exists());
}
}
#[test]
fn accepts_an_exe_only_package() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
install(&exe, &package(&[("openidea.exe", b"new")])).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new");
}
#[test]
fn rejects_incomplete_or_unexpected_payloads_before_changing_files() {
for files in [
vec![("WebView2Loader.dll", b"new".as_slice())],
vec![("openidea.exe", b"new"), ("settings.toml", b"overwrite")],
vec![("openidea.exe", b"new"), ("../outside.txt", b"escape")],
] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
assert!(install(&exe, &package(&files)).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
}
}
#[cfg(windows)]
#[test]
fn updates_a_running_executable_and_restores_on_a_locked_companion() {
use std::os::windows::fs::OpenOptionsExt;
use std::process::{Command, Stdio};
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let ping = Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/PING.EXE");
fs::copy(ping, &exe).unwrap();
let original = fs::read(&exe).unwrap();
let dll = tmp.path().join("WebView2Loader.dll");
fs::write(&dll, b"old dll").unwrap();
let mut command = Command::new(&exe);
command.args(["-n", "30", "127.0.0.1"]).stdout(Stdio::null()).stderr(Stdio::null());
ide_core::process::prepare(&mut command);
let mut child = command.spawn().unwrap();
let result = {
assert!(fs::write(&exe, b"overwrite").is_err(), "fixture must hold the running exe");
let held = fs::OpenOptions::new().read(true).share_mode(1).open(&dll).unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
assert!(install(&exe, &bytes).is_err());
assert_eq!(fs::read(&exe).unwrap(), original);
assert_eq!(fs::read(&dll).unwrap(), b"old dll");
drop(held);
install(&exe, &bytes)
};
let _ = child.kill();
let _ = child.wait();
result.unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(&dll).unwrap(), b"new dll");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
}
#[cfg(windows)]
#[test]
fn restores_the_old_file_when_the_new_file_cannot_be_moved() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let source = tmp.path().join("new.exe");
fs::write(&exe, b"old").unwrap();
fs::write(&source, b"new").unwrap();
let _held = fs::OpenOptions::new().read(true).share_mode(1).open(&source).unwrap();
assert!(replace_file(&source, &exe).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
assert_eq!(fs::read(&source).unwrap(), b"new");
assert!(!backup_path(&exe).exists());
}
}
+2 -1
View File
@@ -85,7 +85,7 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
| Search | `search_text`, `search_regex`, `search_symbol`, `search_file` |
| Editors | `get_all_open_file_paths`, `get_current_file`, `open_file_in_editor` |
| Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` |
| Problems | `get_file_problems`, `get_project_problems` |
| Problems | `get_file_problems`, `get_project_problems`, `check_files` |
| Run and build | `get_run_configurations`, `execute_run_configuration`, `build_project` |
| Commands | `run_command`, `wait_for_run`, `send_run_input`, `stop_run` |
| Command state | `get_run_status`, `read_run_output`, `list_runs` |
@@ -96,6 +96,7 @@ Notes on what they do:
- `read_file` also reads sources of dependencies and files inside jar and zip archives.
- `search_symbol` and `find_references` use the IDE's index and the language servers; `get_symbol_info` returns what Quick Documentation shows.
- `get_file_problems` and `get_project_problems` return what the Problems tool window shows, after your inspection profile is applied, rather than a fresh analysis.
- `check_files` is the fresh analysis, for after an edit made by any tool: it syncs each file from disk, tells its language servers it was saved (so on-save checks such as `cargo check` run) and waits until they answered and finished. Each file gets a `status` that says whether the problems are complete, or why it could not be checked: no language server for it, or one that is not installed, misconfigured or crashed.
- `rename_refactoring` renames through the language server, so references that a text search would miss are updated too. It can preview without writing.
- `apply_patch` accepts the Codex `apply_patch` format and unified diffs. Paths must stay inside the project.
- `git_diff` compares files on disk, so unsaved editor text is not included.
+2 -1
View File
@@ -84,7 +84,7 @@ stdio 配置大致如下(路径以你机器上的为准):
| 搜索 | `search_text`、`search_regex`、`search_symbol`、`search_file` |
| 编辑器 | `get_all_open_file_paths`、`get_current_file`、`open_file_in_editor` |
| 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` |
| 问题 | `get_file_problems`、`get_project_problems` |
| 问题 | `get_file_problems`、`get_project_problems`、`check_files` |
| 运行与构建 | `get_run_configurations`、`execute_run_configuration`、`build_project` |
| 命令 | `run_command`、`wait_for_run`、`send_run_input`、`stop_run` |
| 命令状态 | `get_run_status`、`read_run_output`、`list_runs` |
@@ -95,6 +95,7 @@ stdio 配置大致如下(路径以你机器上的为准):
- `read_file` 还能读取依赖的源代码,以及 jar 和 zip 压缩包中的文件。
- `search_symbol` 和 `find_references` 使用 IDE 的索引和语言服务器;`get_symbol_info` 返回的内容与快速文档相同。
- `get_file_problems` 和 `get_project_problems` 返回的是问题工具窗口当前显示的内容(已应用你的检查配置文件),而不是重新分析的结果。
- `check_files` 才是重新分析,适合在任何工具修改文件之后调用:它从磁盘同步每个文件,通知语言服务器文件已保存(这样 `cargo check` 等保存时检查也会运行),并等到服务器答复且分析完成。每个文件都有一个 `status`,说明问题列表是否完整,或者为什么无法检查:没有对应的语言服务器,或者服务器未安装、配置错误、已崩溃。
- `rename_refactoring` 通过语言服务器重命名,文本搜索找不到的引用也会一并更新;也可以只预览不写入。
- `apply_patch` 接受 Codex 的 `apply_patch` 格式和 unified diff,路径必须在项目内。
- `git_diff` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
+5 -2
View File
@@ -598,6 +598,7 @@ then run `openidea-plugin check`.
| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. |
| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. |
| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. |
| `host::set_file_note` | `decorations.set-file-note` | API 1.7: a note after the name in the project tree (one line, at most 200 characters). Requires `project.read`. |
| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. |
| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. |
| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. |
@@ -851,8 +852,10 @@ Decorations are plain text, with fixed styles mapped to theme tokens:
`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`,
`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items
have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`,
where a badge has at most two Unicode characters. Paths must resolve inside an
open project; absolute paths select that project, relative paths use the invoking
where a badge has at most two Unicode characters. A tree item can instead be a note
(`set-file-note`, API 1.7): muted text after the file or folder name, one line of at
most 200 characters, with an optional tooltip; it replaces that set's item for the file.
Paths must resolve inside an open project; absolute paths select that project, relative paths use the invoking
project. Sets are isolated by plugin and project and shared by all windows of
that project. Setting replaces a set's items for one file; clearing removes that
set in all projects. Each plugin has at most 50 editor sets and 50 tree sets,
+1 -1
View File
@@ -1 +1 @@
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
@@ -1 +1 @@
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
+1 -1
View File
@@ -1 +1 @@
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
+1 -1
View File
@@ -1 +1 @@
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
+1 -1
View File
@@ -1 +1 @@
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
+1 -1
View File
@@ -1 +1 @@
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+1 -1
View File
@@ -1 +1 @@
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
+1 -1
View File
@@ -1 +1 @@
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
+1 -1
View File
@@ -1 +1 @@
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
+1 -1
View File
@@ -1 +1 @@
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
+1 -1
View File
@@ -1 +1 @@
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
+1 -1
View File
@@ -1 +1 @@
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
+1 -1
View File
@@ -1 +1 @@
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
+1 -1
View File
@@ -1 +1 @@
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
+1 -1
View File
@@ -1 +1 @@
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
+16
View File
@@ -11,6 +11,14 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${gh}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${gh}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
};
@@ -21,6 +29,14 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${cnb}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${cnb}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
});
});
+77
View File
@@ -0,0 +1,77 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { afterEach, expect, it, vi } from "vitest";
const workflow = readFileSync(new URL("../.github/workflows/release-build.yml", import.meta.url), "utf8").replaceAll(
"\r\n",
"\n",
);
const job = workflow.split("\n updater-manifest:")[1]!;
const script = job.split(" script: |\n")[1]!.replace(/^ {12}/gm, "");
const run = new Function("github", "context", "core", `return (async () => {${script}\n})();`);
afterEach(() => vi.unstubAllEnvs());
it.each(["v0.1.42", "v0.1.43-nightly.20261112"])(
"publishes signed portable updates for both Windows architectures alongside the installers (%s)",
async (tag) => {
vi.stubEnv("TAG", tag);
vi.stubEnv("RELEASE_ID", "42");
const version = tag.slice(1);
const nightly = tag.includes("-nightly.");
const bundles = [
"OpenIDEA_aarch64.AppImage",
"OpenIDEA_arm64.deb",
"OpenIDEA.aarch64.rpm",
"OpenIDEA_amd64.AppImage",
"OpenIDEA_amd64.deb",
"OpenIDEA.x86_64.rpm",
"OpenIDEA_aarch64.app.tar.gz",
"OpenIDEA_x64.app.tar.gz",
"OpenIDEA_arm64_en-US.msi",
"OpenIDEA_arm64-setup.exe",
"OpenIDEA_x64_en-US.msi",
"OpenIDEA_x64-setup.exe",
`OpenIDEA_${version}_windows_x64_portable.zip`,
`OpenIDEA_${version}_windows_arm64_portable.zip`,
].filter((name) => !nightly || (!name.endsWith(".msi") && !name.endsWith(".rpm")));
const assets = bundles.flatMap((name, id) => [
{ name, id: id * 2 },
{ name: `${name}.sig`, id: id * 2 + 1 },
]);
const upload = vi.fn();
const failed = vi.fn();
await run(
{
paginate: async () => assets,
rest: {
repos: {
listReleaseAssets: vi.fn(),
getRelease: async () => ({ data: { body: "Release notes" } }),
getReleaseAsset: async ({ asset_id }: { asset_id: number }) => ({
data: Buffer.from(`signature-${asset_id}\n`),
}),
uploadReleaseAsset: upload,
},
},
},
{ repo: { owner: "l0ng-ai", repo: "openidea" } },
{ notice: vi.fn(), info: vi.fn(), setFailed: failed },
);
expect(failed).not.toHaveBeenCalled();
const manifest = JSON.parse(upload.mock.calls[0]![0].data);
for (const [arch, filename] of [
["x86_64", "x64"],
["aarch64", "arm64"],
]) {
const name = `OpenIDEA_${version}_windows_${filename}_portable.zip`;
const sig = assets.find((a) => a.name === `${name}.sig`)!;
expect(manifest.platforms[`windows-${arch}-portable`]).toEqual({
signature: `signature-${sig.id}`,
url: `https://github.com/l0ng-ai/openidea/releases/download/${tag}/${name}`,
});
expect(manifest.platforms[`windows-${arch}`].url).toContain(nightly ? "-setup.exe" : "_en-US.msi");
expect(manifest.platforms[`windows-${arch}-nsis`].url).toContain("-setup.exe");
}
},
);
+8
View File
@@ -416,6 +416,9 @@ mod imp {
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
wit::decorations::clear_file(set).map_err(error)
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
wit::decorations::set_file_note(path, set, note, tooltip).map_err(error)
}
pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> {
wit::vcs::log(p, path, limit)
.map(|v| {
@@ -1842,6 +1845,11 @@ pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Resu
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
imp::clear_file_decorations(set)
}
/// Set a note shown after a file's name in the project tree, replacing this tree set's
/// annotation for the file (API 1.7). One line of at most 200 characters. Requires project.read.
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
imp::set_file_note(path, set, note, tooltip)
}
/// Starts an owned run configuration in the invoking window. Its id receives run-exited.
pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> {
+30 -2
View File
@@ -156,6 +156,7 @@ struct State {
restricted: bool,
decorations: BTreeMap<(String, String), Vec<Decoration>>,
file_decorations: BTreeMap<(String, String), FileDecoration>,
file_notes: BTreeMap<(String, String), (String, Option<String>)>,
revealed_ranges: Vec<Range>,
loopback_queries: VecDeque<Option<String>>,
net: Option<NetHandler>,
@@ -300,6 +301,10 @@ impl MockHost {
pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> {
self.state.borrow().file_decorations.clone()
}
/// `(set, path)` → `(note, tooltip)` set with `set_file_note`.
pub fn file_notes(&self) -> BTreeMap<(String, String), (String, Option<String>)> {
self.state.borrow().file_notes.clone()
}
/// Ranges requested to scroll into view.
pub fn revealed_ranges(&self) -> Vec<Range> {
self.state.borrow().revealed_ranges.clone()
@@ -1655,9 +1660,9 @@ pub mod current {
let mut next = s.file_decorations.clone();
next.remove(&(set.into(), path.clone()));
if style(&item.style) {
next.insert((set.into(), path), item.clone());
next.insert((set.into(), path.clone()), item.clone());
}
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1666,6 +1671,7 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1673,6 +1679,28 @@ pub mod current {
with(|s| {
require(s, "project.read")?;
s.file_decorations.retain(|(id, _), _| id != set);
s.file_notes.retain(|(id, _), _| id != set);
Ok(())
})
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
let path = decoration_path(s, path)?;
let key = (set.to_string(), path);
let names: BTreeSet<_> =
s.file_decorations.keys().chain(s.file_notes.keys()).chain([&key]).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
|| note.trim().is_empty()
|| note.chars().count() > 200
|| note.contains(['\n', '\r'])
{
return Err(Error::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
s.file_decorations.remove(&key);
s.file_notes.insert(key, (note.into(), tooltip.map(Into::into)));
Ok(())
})
}
@@ -67,6 +67,14 @@ fn decorations_replace_clear_validate_styles_badges_paths_and_limits() {
host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap();
host::clear_file_decorations("test").unwrap();
assert!(mock.file_decorations().is_empty());
host::set_file_decoration("a.txt", "notes", &file("A")).unwrap();
host::set_file_note("a.txt", "notes", "entry point", Some("why")).unwrap();
assert!(mock.file_decorations().is_empty(), "a note replaces the set's annotation");
assert_eq!(mock.file_notes().values().next(), Some(&("entry point".to_string(), Some("why".to_string()))));
assert!(host::set_file_note("a.txt", "notes", "two\nlines", None).is_err());
assert!(host::set_file_note("a.txt", "notes", &"x".repeat(201), None).is_err());
host::clear_file_decorations("notes").unwrap();
assert!(mock.file_notes().is_empty());
for i in 0..50 {
host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap();
}
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
+19 -5
View File
@@ -464,7 +464,15 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
.collect::<IdeResult<Vec<_>>>()?;
let proxy = ide_core::proxy_settings::proxy();
app.updater_builder()
let mut builder = app.updater_builder();
if let Some(target) = crate::updates::portable_target(
std::env::consts::OS,
std::env::consts::ARCH,
tauri::utils::platform::bundle_type(),
) {
builder = builder.target(target);
}
builder
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
@@ -611,10 +619,16 @@ pub async fn app_install_update(
_ = token.cancelled() => return Err(IdeError::cancelled()),
};
let _ = on_progress.send(UpdateProgress::Installing);
tauri::async_runtime::spawn_blocking(move || update.install(bytes))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::internal(e.to_string()))
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
if update.target.ends_with("-portable") {
let exe = tauri::utils::platform::current_exe()?;
return ide_tools::portable_update::install(&exe, &bytes);
}
update.install(bytes).map_err(|e| IdeError::internal(e.to_string()))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
.await;
state.host.end_request(request);
+6
View File
@@ -176,6 +176,12 @@ pub fn run() {
})
.setup(move |app| {
builder.mount_events(app);
#[cfg(windows)]
tauri::async_runtime::spawn_blocking(|| {
if let Ok(exe) = tauri::utils::platform::current_exe() {
ide_tools::portable_update::cleanup(&exe);
}
});
// An unusable directory variable stops the start with a message, never a silent fallback.
let identifier = app.config().identifier.clone();
let local_data_dir = local_data_dir.and_then(|dir| {
+19
View File
@@ -11,6 +11,11 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tokio::sync::Notify;
/// Tauri patches the installer copies; the raw exe shipped in the portable zip keeps no bundle type.
pub fn portable_target(os: &str, arch: &str, bundle: Option<tauri::utils::config::BundleType>) -> Option<String> {
(os == "windows" && bundle.is_none()).then(|| format!("windows-{arch}-portable"))
}
#[derive(Default)]
pub struct Updates {
started: AtomicBool,
@@ -163,6 +168,20 @@ fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet<String>
mod tests {
use super::*;
#[test]
fn only_unbundled_windows_builds_select_portable_updates() {
use tauri::utils::config::BundleType;
for arch in ["x86_64", "aarch64"] {
assert_eq!(portable_target("windows", arch, None), Some(format!("windows-{arch}-portable")));
for bundle in [BundleType::Msi, BundleType::Nsis] {
assert_eq!(portable_target("windows", arch, Some(bundle)), None);
}
for os in ["linux", "macos"] {
assert_eq!(portable_target(os, arch, None), None);
}
}
}
#[test]
fn notifications_only_target_live_owners_that_have_subscribed() {
let live = vec!["main".into(), "project-2".into()];
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost; frame-ancestors 'none'"
}
},
"bundle": {
+2
View File
@@ -4932,6 +4932,8 @@ export type FileDecoration = {
badge: string | null,
style: string,
tooltip: string | null,
/** Text shown after the name (API 1.7 `set-file-note`). */
note?: string | null,
};
export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[];
+13 -4
View File
@@ -10,6 +10,9 @@ import { gutterIconMarkup, showContextMenu } from "@/ui";
import { emptyGutterSpacer, gutterSpacer } from "@/editor/api";
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { appendGeneratedRequests } from "./generatedRequests";
import { headerCompletion } from "./headers";
import { sourceReader } from "./spring/editor";
import { findHost } from "./spring/request";
import { METHODS } from "./language";
import styles from "./HttpClient.module.css";
@@ -17,13 +20,15 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
export function renderEndpoint(e: EndpointEntry): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
export function renderEndpoint(e: EndpointEntry, host = "http://localhost:8080"): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} ${host}${endpointUrl(e.url)}\n`;
}
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
await appendGeneratedRequests([renderEndpoint(endpoint)]);
// The server address of the endpoint's module (`server.port`, context path), as for Spring mappings.
const host = await findHost(endpoint.path, useProject.getState().project?.root ?? null, sourceReader);
await appendGeneratedRequests([renderEndpoint(endpoint, host)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
@@ -94,7 +99,11 @@ class EndpointMarker extends GutterMarker {
const endpointSpacer = gutterSpacer(styles.runMarker ?? "");
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.language === "http")
return EditorState.languageData.of(() => [
{ openideaCompletion: endpointCompletion },
{ openideaCompletion: headerCompletion },
]);
if (ctx.virtual) return [];
return [
marks,
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { headerTarget } from "./headers";
const at = (text: string) => headerTarget(text.replace("|", ""), text.indexOf("|"));
describe("header completion", () => {
it("completes header names on the lines after the request line", () => {
expect(at("POST http://localhost:8080/users\nCon|")).toEqual({ kind: "name", from: 33, prefix: "Con" });
expect(at("### Create\n# @name create\nPOST {{host}}/users\nAccept: */*\n|")).toMatchObject({
kind: "name",
prefix: "",
});
expect(at("http://localhost/a\nX-Re|")).toMatchObject({ kind: "name", prefix: "X-Re" });
});
it("completes the values of a header", () => {
expect(at("POST http://x/a\nContent-Type: app|")).toEqual({
kind: "value",
name: "Content-Type",
from: 30,
prefix: "app",
});
expect(at("GET http://x/a\nAuthorization:|")).toMatchObject({ kind: "value", name: "Authorization", prefix: "" });
});
it("stays out of request lines, bodies, comments and blocks without a request", () => {
expect(at("POS|")).toBeNull();
expect(at('POST http://x/a\nContent-Type: application/json\n\n{"a|')).toBeNull();
expect(at("POST http://x/a\n# Acc|")).toBeNull();
expect(at("@host = http://x\nAcc|")).toBeNull();
expect(at("POST http://x/a\n\n###\nAcc|")).toBeNull();
});
});
+131
View File
@@ -0,0 +1,131 @@
/** Header name and value completion in `.http` request blocks (the lines after the request line). */
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { METHODS } from "./language";
const REQUEST_LINE = new RegExp(`^\\s*(?:(?:${METHODS.join("|")})\\s+\\S|https?://|\\{\\{)`);
const COMMENT = /^\s*(?:#|\/\/)/;
const HEADERS = [
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Authorization",
"Cache-Control",
"Connection",
"Content-Disposition",
"Content-Encoding",
"Content-Language",
"Content-Length",
"Content-Type",
"Cookie",
"Date",
"Expect",
"Forwarded",
"From",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
"Max-Forwards",
"Origin",
"Pragma",
"Proxy-Authorization",
"Range",
"Referer",
"TE",
"Upgrade",
"User-Agent",
"Via",
"X-Correlation-ID",
"X-CSRF-Token",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Request-ID",
"X-Requested-With",
];
const MEDIA_TYPES = [
"application/json",
"application/xml",
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
"text/html",
"text/xml",
"text/csv",
"application/octet-stream",
"application/graphql",
"application/x-ndjson",
"application/problem+json",
"*/*",
];
const VALUES: Record<string, string[]> = {
accept: MEDIA_TYPES,
"content-type": MEDIA_TYPES,
"accept-encoding": ["gzip, deflate, br", "gzip", "deflate", "br", "identity"],
"content-encoding": ["gzip", "deflate", "br"],
"accept-charset": ["utf-8", "iso-8859-1"],
"accept-language": ["en-US", "en", "zh-CN", "zh"],
authorization: ["Bearer ", "Basic "],
"proxy-authorization": ["Bearer ", "Basic "],
"cache-control": ["no-cache", "no-store", "max-age=0", "must-revalidate"],
pragma: ["no-cache"],
connection: ["keep-alive", "close", "Upgrade"],
upgrade: ["websocket", "h2c"],
expect: ["100-continue"],
"x-requested-with": ["XMLHttpRequest"],
"content-disposition": ["attachment", "inline", 'form-data; name=""'],
"access-control-request-method": METHODS,
};
export type HeaderTarget =
| { kind: "name"; from: number; prefix: string }
| { kind: "value"; name: string; from: number; prefix: string };
/** What is being typed at `pos`: a header name or a header's value, or null outside a request's headers. */
export function headerTarget(text: string, pos: number): HeaderTarget | null {
const before = text.slice(0, pos);
const lineStart = before.lastIndexOf("\n") + 1;
const line = before.slice(lineStart);
if (COMMENT.test(line)) return null;
const lines = (
before
.slice(0, lineStart)
.split(/^\s*###.*$/m)
.at(-1) ?? ""
)
.split("\n")
.slice(0, -1);
let request = false;
for (const l of lines) {
if (COMMENT.test(l)) continue;
if (!request) request = REQUEST_LINE.test(l);
// A blank line ends the headers; the body follows.
else if (!l.trim()) return null;
}
if (!request) return null;
const colon = line.indexOf(":");
if (colon < 0) return /^[\w-]*$/.test(line) ? { kind: "name", from: lineStart, prefix: line } : null;
const prefix = line.slice(colon + 1).replace(/^\s+/, "");
return { kind: "value", name: line.slice(0, colon).trim(), from: pos - prefix.length, prefix };
}
export function headerCompletion(context: CompletionContext): CompletionResult | null {
const target = headerTarget(context.state.doc.toString(), context.pos);
if (!target) return null;
if (target.kind === "name") {
if (!target.prefix && !context.explicit) return null;
const options: Completion[] = HEADERS.map((name) => ({ label: name, type: "property", apply: `${name}: ` }));
return { from: target.from, options, validFor: /^[\w-]*$/ };
}
const values = VALUES[target.name.toLowerCase()];
if (!values) return null;
return { from: target.from, options: values.map((v) => ({ label: v, type: "constant" })) };
}
+4 -3
View File
@@ -21,7 +21,7 @@ export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
const reader: SourceReader = {
export const sourceReader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
@@ -29,6 +29,7 @@ const reader: SourceReader = {
return null;
}
},
filesNamed: (name) => call(commands.findFilesNamed(name)),
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
@@ -43,12 +44,12 @@ export async function generateRequests(sourcePath: string, text: string, targets
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, reader),
findHost(sourcePath, root, sourceReader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
reader,
sourceReader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
+100 -37
View File
@@ -115,9 +115,18 @@ export function skeleton(type: string, lookup: (name: string) => ClassDecl | nul
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
// Superclass properties first, as Jackson writes them.
const chain = [cls];
for (let c = cls; c.superclass && chain.length <= MAX_SUPERCLASSES;) {
const parent = lookup(bareType(c.superclass).replace(/<.*$/, ""));
if (!parent || chain.includes(parent)) break;
chain.unshift((c = parent));
}
const obj: { [k: string]: Json } = {};
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
for (const c of chain) {
for (const p of c.ctorParams) if (!(p.name in obj)) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of c.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
}
return obj;
}
@@ -175,34 +184,52 @@ export function appendRequests(existing: string, blocks: string[]): { insert: st
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
/** The project's files called `name` (from the index). */
filesNamed?(name: string): Promise<string[]>;
}
/** How many superclasses of a body type are followed for their fields. */
const MAX_SUPERCLASSES = 5;
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
/** The Spring Boot configuration files (of `profile`) for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null, profile?: string): string[] {
const norm = sourcePath.replace(/\\/g, "/");
const at = norm.lastIndexOf("/src/main/");
const roots = at >= 0 ? [norm.slice(0, at)] : [];
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
return roots.flatMap((r) =>
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
["properties", "yml", "yaml"].map((x) => `${r}/src/main/resources/application${profile ? `-${profile}` : ""}.${x}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
const configs: Map<string, string>[] = [];
for (const path of configCandidates(sourcePath, projectRoot)) {
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return hostFromConfig(configs);
const read = async (paths: string[]) => {
const configs: Map<string, string>[] = [];
for (const path of paths) {
const text = await fs.read(path).catch(() => null);
if (text !== null) configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return configs;
};
const base = await read(configCandidates(sourcePath, projectRoot));
// `spring.profiles.active=dev`: `application-dev.*` overrides the base files (the last profile wins).
const active = base.map((c) => resolved(c.get("spring.profiles.active"))).find(Boolean);
const profiles = (active ?? "")
.split(",")
.map((p) => p.trim())
.filter((p) => /^[\w.-]+$/.test(p))
.reverse();
const profiled = await read(profiles.flatMap((p) => configCandidates(sourcePath, projectRoot, p)));
return hostFromConfig([...profiled, ...base]);
}
/**
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
* Body types of `endpoints`: classes of the controller's file, then `<Type>.java|.kt` beside
* it or where its import points (same source root), else the project file of that name in the
* imported (or same / wildcard-imported) package, e.g. a DTO in another Maven module. Their
* superclasses are loaded too.
*/
export async function bodyLookup(
sourcePath: string,
@@ -211,42 +238,78 @@ export async function bodyLookup(
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
/** Where each class was read: names in its declarations resolve from that file. */
const origins = new Map<string, Origin>();
const add = (o: Origin) => {
for (const c of allClasses(o.file))
if (!known.has(c.name)) {
known.set(c.name, c);
origins.set(c.name, o);
}
};
add(file);
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
add(origin);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const readAt = async (path: string) => {
const text = await fs.read(path).catch(() => null);
if (text === null) return null;
const loaded = { path, file: parseSource(text) };
add(loaded);
return loaded;
};
/** Loads `name` as seen from `from` (its imports, then its package). */
const load = async (name: string, from: Origin) => {
if (known.has(name)) return origins.get(name) ?? null;
if (!/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
const packages = imported
? [imported.split(".").slice(0, -1).join(".")]
: [from.file.packageName, ...from.file.imports.filter((i) => i.endsWith(".*")).map((i) => i.slice(0, -2))];
const root = sourceRoot(from.path, from.file);
const dirs = new Set([...packages.map((p) => (p ? `${root}/${p.replace(/\./g, "/")}` : root)), dirname(from.path)]);
for (const d of dirs)
for (const x of ext) {
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
const loaded = await readAt(`${d}/${name}${x}`);
if (loaded) return loaded;
}
// Another source root or module: the project's files of that name, in one of those packages.
if (!fs.filesNamed) return null;
const suffixes = packages.map((p) => `/${p ? `${p.replace(/\./g, "/")}/` : ""}${name}`);
for (const x of ext) {
const paths = (await fs.filesNamed(`${name}${x}`).catch(() => [])).map((p) => p.replace(/\\/g, "/"));
const path = paths.find((p) => suffixes.some((s) => p.endsWith(`${s}${x}`)));
const loaded = path ? await readAt(path) : null;
if (loaded) return loaded;
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
/** `name`, its superclasses, and the property types of each, one level down. */
const loadWithProperties = async (name: string, from: Origin, depth: number) => {
const seen = new Set<string>();
let [current, at]: [string | undefined, Origin | null] = [name, from];
while (current && at && !seen.has(current) && seen.size < MAX_SUPERCLASSES) {
seen.add(current);
const loaded = await load(current, at);
const c = known.get(current);
if (!c || !loaded) break;
if (depth > 0)
for (const p of [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)])
await loadWithProperties(typeName(p.type), loaded, depth - 1);
[current, at] = [c.superclass && typeName(c.superclass), loaded];
}
};
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
for (const n of names) await loadWithProperties(n, origin, 1);
return (name) => known.get(name) ?? null;
}
interface Origin {
path: string;
file: SourceFile;
}
+28
View File
@@ -65,6 +65,8 @@ export interface ClassDecl {
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
/** The class it extends (`extends Base<T>` / Kotlin `: Base()`), as written. */
superclass?: string;
}
export interface SourceFile {
@@ -220,6 +222,26 @@ class Reader {
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** A type name at `i` (`a.b.Base<T>`): its text and the index after it. */
typeName(i: number, to: number): { name: string; end: number } | null {
let j = i;
while (j < to && this.toks[j]?.kind === "id" && (j === i || this.is(j - 1, "."))) {
j++;
if (!this.is(j, ".") || this.toks[j + 1]?.kind !== "id") break;
j++;
}
if (j === i) return null;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
return { name: joined(this.toks.slice(i, j)), end: j };
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
@@ -415,6 +437,12 @@ class Reader {
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
// Java `extends Base`; Kotlin `: Base(…)` (a supertype called with arguments is the class).
const javaSuper = this.toks[j]!.kind === "id" && this.toks[j]!.text === "extends";
if (!cls.superclass && (javaSuper || this.is(j, ":") || this.is(j, ","))) {
const type = this.typeName(j + 1, to);
if (type && (javaSuper || this.is(type.end, "("))) cls.superclass = type.name;
}
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
@@ -252,6 +252,19 @@ describe("server address", () => {
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
});
it("reads the active profiles' files over the base ones", async () => {
const files: Record<string, string> = {
"/ws/api/src/main/resources/application.yml":
"server:\n port: 8001\n servlet:\n context-path: /api\nspring:\n profiles:\n active: ${PROFILE:local}, dev\n",
"/ws/api/src/main/resources/application-local.properties": "server.port=7000\n",
"/ws/api/src/main/resources/application-dev.yml": "server:\n port: 9100\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:9100/api");
delete files["/ws/api/src/main/resources/application-dev.yml"];
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:7000/api");
});
});
describe("body types from other files", () => {
@@ -271,6 +284,41 @@ describe("body types from other files", () => {
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
it("finds a DTO in another module through the index, with its superclass fields", async () => {
const files: Record<string, string> = {
"/ws/common/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\nimport com.example.base.BaseEntity;\n@Data\npublic class CreateUser extends BaseEntity<Long> implements Serializable {\n private String name;\n private Address address;\n}\n",
"/ws/common/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic class Address { private String city; }\n",
"/ws/base/src/main/java/com/example/base/BaseEntity.java":
"package com.example.base;\npublic abstract class BaseEntity<ID> { private ID id; private LocalDateTime createdAt; }\n",
// Same name, other package: not the imported class.
"/ws/legacy/src/main/java/com/legacy/CreateUser.java":
"package com.legacy;\npublic class CreateUser { int old; }\n",
};
const fs: SourceReader = {
read: async (p) => files[p] ?? null,
filesNamed: async (name) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
};
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup(
"/ws/web/src/main/java/com/example/demo/web/UserController.java",
parseSource(JAVA),
eps,
fs,
);
expect(skeleton("CreateUser", lookup)).toEqual({ id: {}, createdAt: "", name: "", address: { city: "" } });
});
it("reads Java and Kotlin superclasses", () => {
const java = parseSource("class A extends com.x.Base<T> implements I { }\ninterface J extends K {}");
expect(java.classes.map((c) => c.superclass)).toEqual(["com.x.Base<T>", "K"]);
const kotlin = parseSource(
"class A(val a: Int) : Base(a), Serializable\nclass B : Serializable, Other()\nclass C : I",
);
expect(kotlin.classes.map((c) => c.superclass)).toEqual(["Base", "Other", undefined]);
});
});
describe("appending to the request file", () => {
+15 -1
View File
@@ -83,6 +83,14 @@ export function touchesManifest(paths: readonly string[]): boolean {
);
}
/** IDEA's order, whatever order the ecosystems were detected in: SDKs first, then libraries, each by name. */
function sortedGroups(groups: readonly LibraryGroup[]): LibraryGroup[] {
const rank = (g: LibraryGroup) => (SDK_KINDS.has(g.kind) ? 0 : 1);
return [...groups].sort(
(a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true }),
);
}
/** The "External Libraries" node for `groups` (`< Python 3.12 (.venv) >` › `python3.12`, `site-packages`). */
export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootContribution | null {
if (!groups.length) return null;
@@ -93,7 +101,7 @@ export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootC
// After the project, before Scratches and Consoles (IDEA's order).
order: 5,
readOnly: true,
children: groups.map((g) => ({
children: sortedGroups(groups).map((g) => ({
label: SDK_KINDS.has(g.kind) ? `< ${g.name} >` : g.name,
icon: GROUP_ICONS[g.kind] ?? "library",
children: g.roots.map((r) => ({
@@ -192,12 +200,18 @@ export function activate(): () => void {
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
// A Maven / Gradle sync changes the dependency versions without touching a manifest.
const unsubRoots = onIdeEvent("projectRootsChanged", () => {
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
return () => {
seq++;
if (request !== undefined) void commands.cancelRequest(request).catch(() => {});
clearTimeout(timer);
unsub();
unsubFs();
unsubRoots();
disposeActions();
clear();
};
+39 -11
View File
@@ -37,18 +37,46 @@ describe("External Libraries", () => {
}),
),
)!;
expect(root.children?.map((g) => g.icon)).toEqual([
"file-java",
"file-js",
"file-npm",
"file-rust",
"file-cargo",
"file-go",
"file-gomod",
"maven-project",
"file-gradle",
"library",
expect(Object.fromEntries(root.children!.map((g) => [g.label, g.icon]))).toEqual({
"< jdk >": "file-java",
nodeCore: "file-js",
nodeModules: "file-npm",
"< rust >": "file-rust",
cargo: "file-cargo",
"< go >": "file-go",
goModules: "file-gomod",
maven: "maven-project",
gradle: "file-gradle",
other: "library",
});
});
it("lists SDKs first, then libraries by name, whatever order they were detected in", () => {
const group = (name: string, kind: string) => ({ name, kind, roots: [{ name: "r", path: `/libs/${name}` }] });
const detected = [
group("Maven: org.junit:junit:4.13", "maven"),
group("temurin-21", "jdk"),
group("node_modules", "nodeModules"),
group("Python 3.14", "python"),
group("Cargo dependencies", "cargo"),
group("Gradle: com.acme:lib:1.0", "gradle"),
group("Go SDK 1.27.4", "go"),
group("Maven: com.acme:core:2.1.27", "maven"),
group("Node.js Core", "nodeCore"),
];
const labels = (groups: typeof detected) => librariesRoot(groups)!.children!.map((g) => g.label);
expect(labels(detected)).toEqual([
"< Go SDK 1.27.4 >",
"< Python 3.14 >",
"< temurin-21 >",
"Cargo dependencies",
"Gradle: com.acme:lib:1.0",
"Maven: com.acme:core:2.1.27",
"Maven: org.junit:junit:4.13",
"node_modules",
"Node.js Core",
]);
expect(labels([...detected].reverse())).toEqual(labels(detected));
});
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
@@ -66,6 +66,19 @@ it("filters project roots and unknown styles and replaces tree badges", () => {
expect(getFileDecoration("/p/a.txt")?.badge).toBeUndefined();
});
it("shows a tree note after the name without colouring the file", () => {
const note = {
...set(),
tree: true,
items: [],
file: { style: "", badge: null, tooltip: "why", note: "entry point" },
};
setPluginDecorations("test", [note]);
expect(getFileDecoration("/p/a.txt")).toMatchObject({ suffix: [{ text: "entry point" }], tooltip: "why" });
expect(getFileDecoration("/p/a.txt")?.color).toBeUndefined();
setPluginDecorations("test", []);
});
it("selects UTF-16 ranges and reveals without changing text", () => {
handleRuntimeEvent({ type: "editorRanges", path: ctx.path, ranges: [range], reveal: false });
expect(editor.view.state.selection.main.from).toBe(2);
+4 -2
View File
@@ -32,13 +32,15 @@ function currentSets(sets: DecorationSet[]) {
function updateTree(plugin: string, sets: DecorationSet[]) {
const items = new Map<string, FileDecoration>();
for (const s of currentSets(sets))
if (s.tree && s.file && Object.hasOwn(colors, s.file.style)) {
if (s.tree && s.file && (Object.hasOwn(colors, s.file.style) || s.file.note)) {
items.set(s.path, {
color: colors[s.file.style],
color: Object.hasOwn(colors, s.file.style) ? colors[s.file.style] : undefined,
badge: s.file.badge ?? undefined,
tooltip: s.file.tooltip ?? undefined,
faded: s.file.style === "muted",
error: s.file.style === "error",
// A note: muted text after the name, like the VCS branch of a root.
suffix: s.file.note ? [{ text: s.file.note }] : undefined,
});
}
setFileDecorations(`plugin:${plugin}`, items);
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/** The app's CSP, which Tauri sends as a response header with every HTML page it serves. */
function directives(): Map<string, string[]> {
const conf = JSON.parse(readFileSync("src-tauri/tauri.conf.json", "utf8"));
const csp: string = conf.app.security.csp;
const map = new Map<string, string[]>();
for (const part of csp.split(";")) {
const [name, ...sources] = part.trim().split(/\s+/);
if (name) map.set(name.toLowerCase(), sources);
}
return map;
}
describe("app content security policy", () => {
it("does not let any page embed the app's own pages", () => {
expect(directives().get("frame-ancestors")).toEqual(["'none'"]);
});
});
+3
View File
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}