Compare commits

..
Author SHA1 Message Date
l0ng-ai c48becbfff fix(ui): keep Select All in a reading pane out of the composer
⌘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:32:23 +08:00
89 changed files with 133 additions and 1534 deletions

No files matched your search

-9
View File
@@ -151,13 +151,6 @@ 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.
@@ -240,8 +233,6 @@ 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
@@ -1,176 +0,0 @@
//! `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> {
})
}
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
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(),
+9 -34
View File
@@ -30,14 +30,9 @@ 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; 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.
By default, reads up to 2000 lines starting from the beginning of the file.
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 {
@@ -55,7 +50,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, window)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// 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 `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 are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize) -> 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 {
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
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;
@@ -261,7 +245,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max, false).unwrap()
render_slice(text, start, max).unwrap()
}
#[test]
@@ -270,27 +254,18 @@ 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, 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"));
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"));
}
#[test]
fn keeps_head_and_tail_of_a_long_default_read() {
fn keeps_head_and_tail_of_long_slices() {
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,5 +1,4 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
@@ -32,7 +31,6 @@ 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.
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
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,
+2 -131
View File
@@ -38,14 +38,6 @@ 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()]
@@ -57,8 +49,6 @@ 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/\"].",
@@ -67,7 +57,6 @@ 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())
}
@@ -77,8 +66,6 @@ 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.
@@ -88,7 +75,6 @@ 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())
}
@@ -98,7 +84,6 @@ 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.
@@ -114,7 +99,6 @@ 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())
}
@@ -161,13 +145,6 @@ 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 {
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
"name": string, "container": string,
},
"required": ["filePath"],
} },
@@ -232,87 +207,6 @@ 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.
@@ -522,7 +416,6 @@ 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.
@@ -601,7 +494,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 mut items: Vec<SearchItem> = hits
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -612,9 +505,6 @@ 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 })
}
@@ -836,7 +726,6 @@ 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();
@@ -886,13 +775,6 @@ 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)
@@ -987,17 +869,6 @@ 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();
+1 -2
View File
@@ -313,11 +313,10 @@ 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
+2 -30
View File
@@ -4125,11 +4125,6 @@ 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()));
@@ -4148,7 +4143,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
file: file.filter(|f| decoration_style(&f.style)),
})
} else {
None
@@ -4320,33 +4315,10 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: 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,16 +112,6 @@ 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;
@@ -464,25 +454,6 @@ 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,9 +22,6 @@ 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 {
@@ -51,29 +48,6 @@ 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 {
@@ -224,96 +198,6 @@ 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::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
pub use inspect::{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,24 +658,6 @@ 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,57 +2178,6 @@ 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,9 +922,6 @@ 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)]
+1 -12
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, note: None }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
)
.map_err(to_wit))
}
@@ -2507,17 +2507,6 @@ 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 @@
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
@@ -1 +1 @@
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
@@ -1 +1 @@
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
@@ -1 +1 @@
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
@@ -1 +1 @@
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
@@ -1 +1 @@
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
@@ -1 +1 @@
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
@@ -1 +1 @@
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
@@ -1 +1 @@
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
@@ -1 +1 @@
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
@@ -1 +1 @@
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
@@ -1 +1 @@
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
@@ -1 +1 @@
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
@@ -1 +1 @@
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
@@ -1 +1 @@
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
@@ -1 +1 @@
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
@@ -1 +1 @@
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
@@ -1 +1 @@
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
@@ -1 +1 @@
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
@@ -1 +1 @@
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
@@ -1 +1 @@
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
@@ -1 +1 @@
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
@@ -1 +1 @@
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
@@ -1 +1 @@
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
@@ -1 +1 @@
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
@@ -1 +1 @@
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
@@ -1 +1 @@
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
@@ -1 +1 @@
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
+2
View File
@@ -19,6 +19,8 @@ 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,8 +20,6 @@ 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
@@ -1,223 +0,0 @@
//! 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());
}
}
+1 -2
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`, `check_files` |
| Problems | `get_file_problems`, `get_project_problems` |
| 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,7 +96,6 @@ 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.
+1 -2
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`、`check_files` |
| 问题 | `get_file_problems`、`get_project_problems` |
| 运行与构建 | `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,7 +95,6 @@ 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` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
+2 -5
View File
@@ -598,7 +598,6 @@ 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. |
@@ -852,10 +851,8 @@ 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. 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
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
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 @@
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
@@ -1 +1 @@
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
+1 -1
View File
@@ -1 +1 @@
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
+1 -1
View File
@@ -1 +1 @@
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
+1 -1
View File
@@ -1 +1 @@
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
+1 -1
View File
@@ -1 +1 @@
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
+1 -1
View File
@@ -1 +1 @@
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
+1 -1
View File
@@ -1 +1 @@
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
+1 -1
View File
@@ -1 +1 @@
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
+1 -1
View File
@@ -1 +1 @@
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
+1 -1
View File
@@ -1 +1 @@
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
+1 -1
View File
@@ -1 +1 @@
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
+1 -1
View File
@@ -1 +1 @@
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
+1 -1
View File
@@ -1 +1 @@
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
+1 -1
View File
@@ -1 +1 @@
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
-16
View File
@@ -11,14 +11,6 @@ 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`,
},
},
};
@@ -29,14 +21,6 @@ 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
@@ -1,77 +0,0 @@
// @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,9 +416,6 @@ 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| {
@@ -1845,11 +1842,6 @@ 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> {
+2 -30
View File
@@ -156,7 +156,6 @@ 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>,
@@ -301,10 +300,6 @@ 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()
@@ -1660,9 +1655,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.clone()), item.clone());
next.insert((set.into(), path), item.clone());
}
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1671,7 +1666,6 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1679,28 +1673,6 @@ 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,14 +67,6 @@ 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,7 +19,4 @@ 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>;
}
+5 -19
View File
@@ -464,15 +464,7 @@ 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();
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
app.updater_builder()
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
@@ -619,16 +611,10 @@ pub async fn app_install_update(
_ = token.cancelled() => return Err(IdeError::cancelled()),
};
let _ = on_progress.send(UpdateProgress::Installing);
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()))?
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()))
}
.await;
state.host.end_request(request);
-6
View File
@@ -176,12 +176,6 @@ 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,11 +11,6 @@ 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,
@@ -168,20 +163,6 @@ 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; frame-ancestors 'none'"
"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"
}
},
"bundle": {
-2
View File
@@ -4932,8 +4932,6 @@ 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[];
+4 -13
View File
@@ -10,9 +10,6 @@ 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";
@@ -20,15 +17,13 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
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 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 async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
// 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)]);
await appendGeneratedRequests([renderEndpoint(endpoint)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
@@ -99,11 +94,7 @@ 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 },
{ openideaCompletion: headerCompletion },
]);
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.virtual) return [];
return [
marks,
-33
View File
@@ -1,33 +0,0 @@
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
@@ -1,131 +0,0 @@
/** 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" })) };
}
+3 -4
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";
export const sourceReader: SourceReader = {
const reader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
@@ -29,7 +29,6 @@ export const sourceReader: SourceReader = {
return null;
}
},
filesNamed: (name) => call(commands.findFilesNamed(name)),
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
@@ -44,12 +43,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, sourceReader),
findHost(sourcePath, root, reader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
sourceReader,
reader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
+37 -100
View File
@@ -115,18 +115,9 @@ 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 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);
}
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);
return obj;
}
@@ -184,52 +175,34 @@ 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 (of `profile`) for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null, profile?: string): string[] {
/** 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[] {
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) =>
["properties", "yml", "yaml"].map((x) => `${r}/src/main/resources/application${profile ? `-${profile}` : ""}.${x}`),
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
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]);
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);
}
/**
* 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.
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
*/
export async function bodyLookup(
sourcePath: string,
@@ -238,78 +211,42 @@ export async function bodyLookup(
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
/** 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);
}
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
};
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
add(origin);
add(file);
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"];
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;
/** 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 imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
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)
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])
for (const x of ext) {
const loaded = await readAt(`${d}/${name}${x}`);
if (loaded) return loaded;
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 };
}
// 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(/<.*$/, "");
/** `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 origin = { path: sourcePath.replace(/\\/g, "/"), file };
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) await loadWithProperties(n, origin, 1);
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);
}
return (name) => known.get(name) ?? null;
}
interface Origin {
path: string;
file: SourceFile;
}
-28
View File
@@ -65,8 +65,6 @@ 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 {
@@ -222,26 +220,6 @@ 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];
@@ -437,12 +415,6 @@ 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,19 +252,6 @@ 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", () => {
@@ -284,41 +271,6 @@ 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", () => {
+1 -15
View File
@@ -83,14 +83,6 @@ 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;
@@ -101,7 +93,7 @@ export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootC
// After the project, before Scratches and Consoles (IDEA's order).
order: 5,
readOnly: true,
children: sortedGroups(groups).map((g) => ({
children: groups.map((g) => ({
label: SDK_KINDS.has(g.kind) ? `< ${g.name} >` : g.name,
icon: GROUP_ICONS[g.kind] ?? "library",
children: g.roots.map((r) => ({
@@ -200,18 +192,12 @@ 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();
};
+11 -39
View File
@@ -37,46 +37,18 @@ describe("External Libraries", () => {
}),
),
)!;
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(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(labels([...detected].reverse())).toEqual(labels(detected));
});
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
@@ -66,19 +66,6 @@ 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);
+2 -4
View File
@@ -32,15 +32,13 @@ 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) || s.file.note)) {
if (s.tree && s.file && Object.hasOwn(colors, s.file.style)) {
items.set(s.path, {
color: Object.hasOwn(colors, s.file.style) ? colors[s.file.style] : undefined,
color: colors[s.file.style],
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
@@ -1,21 +0,0 @@
// @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,7 +19,4 @@ 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>;
}