mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48becbfff |
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))) = ¤t 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
|
||||
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
|
||||
@@ -1 +1 @@
|
||||
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
|
||||
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
|
||||
+1
-1
@@ -1 +1 @@
|
||||
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
|
||||
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
|
||||
@@ -1 +1 @@
|
||||
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
|
||||
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
|
||||
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
|
||||
+1
-1
@@ -1 +1 @@
|
||||
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
|
||||
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
|
||||
+1
-1
@@ -1 +1 @@
|
||||
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
|
||||
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
|
||||
+1
-1
@@ -1 +1 @@
|
||||
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
|
||||
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
|
||||
@@ -1 +1 @@
|
||||
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
|
||||
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
|
||||
+1
-1
@@ -1 +1 @@
|
||||
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
|
||||
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
|
||||
+1
-1
@@ -1 +1 @@
|
||||
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
|
||||
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
|
||||
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
|
||||
+1
-1
@@ -1 +1 @@
|
||||
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
|
||||
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
|
||||
+1
-1
@@ -1 +1 @@
|
||||
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
|
||||
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
|
||||
+1
-1
@@ -1 +1 @@
|
||||
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
|
||||
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" })) };
|
||||
}
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'"]);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user