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 | |
|---|---|---|---|
|
|
19072fbfce |
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) {
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
@@ -296,16 +296,6 @@ pub struct McpServerSettings {
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
}
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
|
||||
@@ -60,8 +60,6 @@ pub struct Host {
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
@@ -143,7 +141,6 @@ impl Host {
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
@@ -272,7 +269,6 @@ impl Host {
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.mcp_agent_runs.forget_window(window);
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
|
||||
@@ -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,9 +1,8 @@
|
||||
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
|
||||
mod check;
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
pub(crate) mod run;
|
||||
mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
@@ -17,7 +16,6 @@ use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -32,7 +30,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());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,418 +0,0 @@
|
||||
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
|
||||
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUN_TIMEOUT_MS: usize = 10_000;
|
||||
const WAIT_TIMEOUT_MS: usize = 30_000;
|
||||
const STOP_TIMEOUT_MS: usize = 10_000;
|
||||
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
|
||||
const DEFAULT_MAX_LINES: usize = 2000;
|
||||
/// Bytes of the log read for one result at most.
|
||||
const READ_CAP: usize = 1 << 20;
|
||||
/// Lines of output quoted when a name is still running.
|
||||
const BUSY_TAIL_LINES: usize = 20;
|
||||
|
||||
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
|
||||
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
|
||||
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
|
||||
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
|
||||
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
|
||||
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
|
||||
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
|
||||
wait for their confirmation before you continue.";
|
||||
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
|
||||
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
|
||||
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
|
||||
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
|
||||
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
call.host()
|
||||
.mcp_agent_runs
|
||||
.get(call.root(), &name)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
|
||||
}
|
||||
|
||||
/// `status` of `run`, with interventions not told yet.
|
||||
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
|
||||
let mut out = run.status();
|
||||
out.extend(extra);
|
||||
let interventions = run.take_interventions();
|
||||
if !interventions.is_empty() {
|
||||
out.insert("interventions".into(), interventions.into());
|
||||
out.insert("advice".into(), INTERVENTION_ADVICE.into());
|
||||
}
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
|
||||
/// as `truncateMode` says.
|
||||
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
|
||||
let max_lines = match call.opt_i64("maxLinesCount")? {
|
||||
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
|
||||
Some(n) => n as usize,
|
||||
None => DEFAULT_MAX_LINES,
|
||||
};
|
||||
let mode = match call.opt_str("truncateMode")? {
|
||||
Some(name) => TruncateMode::parse(&name)
|
||||
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
|
||||
None => TruncateMode::Start,
|
||||
};
|
||||
let len = run.log_len();
|
||||
let from = from.min(len);
|
||||
// A long output is read from the end that stays.
|
||||
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
|
||||
let (raw, start, end) = run.read(start, READ_CAP)?;
|
||||
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
|
||||
let mut out = Map::new();
|
||||
out.insert("output".into(), text.into());
|
||||
if truncated > 0 {
|
||||
out.insert("truncatedLines".into(), truncated.into());
|
||||
}
|
||||
out.insert("outputStart".into(), start.into());
|
||||
out.insert("nextOffset".into(), end.into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
|
||||
Ok(tokio::time::Instant::now() + timeout(call, default)?)
|
||||
}
|
||||
|
||||
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
|
||||
call.opt_str("pattern")?
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
|
||||
match call.opt_i64(name)? {
|
||||
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
|
||||
n => Ok(n.map(|n| n as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
fn busy(run: &AgentRun) -> IdeError {
|
||||
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
|
||||
IdeError::invalid(format!(
|
||||
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
|
||||
run.name,
|
||||
run.command(),
|
||||
clean_output(&tail).trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_command() -> Tool {
|
||||
Tool::new(
|
||||
"run_command",
|
||||
&format!(
|
||||
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
|
||||
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
|
||||
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
|
||||
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
|
||||
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
|
||||
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
|
||||
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
let command = call.str("command")?;
|
||||
if command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("command must not be empty"));
|
||||
}
|
||||
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
|
||||
Some(dir) => {
|
||||
let path = call.resolve_inside(&dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
|
||||
}
|
||||
path
|
||||
}
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
let runs = &host.mcp_agent_runs;
|
||||
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
|
||||
return Err(busy(&run));
|
||||
}
|
||||
let shown_cwd = call.display(&cwd);
|
||||
let mut detail = format!(
|
||||
"{command}\n\nTab: {name}\nWorking directory: {}",
|
||||
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
|
||||
);
|
||||
for (key, value) in env.iter().flatten() {
|
||||
detail.push_str(&format!("\n{key}={value}"));
|
||||
}
|
||||
call.confirm(&name, Some(detail)).await?;
|
||||
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
|
||||
let request = ide_core::event::McpUiRequest::AgentRun {
|
||||
name: name.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.into(),
|
||||
env: env.unwrap_or_default().into_iter().collect(),
|
||||
};
|
||||
if let Err(e) = call.ask(request).await {
|
||||
let error = run.start_error();
|
||||
runs.abandon(&run);
|
||||
return Err(match error {
|
||||
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(error) = run.start_error() {
|
||||
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
|
||||
}
|
||||
let from = run.process_offset();
|
||||
let end = run.wait(deadline, None, from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::required(
|
||||
"command",
|
||||
ParamType::String,
|
||||
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Directory inside the project to run in, relative to the project root; default: the project root",
|
||||
))
|
||||
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
|
||||
.param(Param::optional(
|
||||
"timeout",
|
||||
ParamType::Integer,
|
||||
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
|
||||
keeps running after it",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn wait_for_run() -> Tool {
|
||||
Tool::new(
|
||||
"wait_for_run",
|
||||
&format!(
|
||||
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
|
||||
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
|
||||
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
|
||||
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
|
||||
let pattern = pattern(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
|
||||
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
|
||||
.param(Param::optional(
|
||||
"pattern",
|
||||
ParamType::String,
|
||||
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"offset",
|
||||
ParamType::Integer,
|
||||
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
|
||||
command's output starts",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn read_run_output() -> Tool {
|
||||
Tool::new(
|
||||
"read_run_output",
|
||||
&format!(
|
||||
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
|
||||
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
|
||||
{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or(0);
|
||||
let extra = output_view(&call, &run, from)?;
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn get_run_status() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_status",
|
||||
&format!(
|
||||
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
|
||||
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
|
||||
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
|
||||
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
}
|
||||
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Key {
|
||||
/// Writes this text to its input.
|
||||
Text(&'static str),
|
||||
/// Ctrl+C: stops it, as `stop_run` does.
|
||||
Interrupt,
|
||||
/// Ctrl+D: ends its input.
|
||||
EndOfInput,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> IdeResult<Key> {
|
||||
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
|
||||
"enter" | "return" => Key::Text("\n"),
|
||||
"tab" => Key::Text("\t"),
|
||||
"ctrl+c" => Key::Interrupt,
|
||||
"ctrl+d" => Key::EndOfInput,
|
||||
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
|
||||
Ctrl+C to stop it or Ctrl+D to end its input"
|
||||
)));
|
||||
}
|
||||
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_run_input() -> Tool {
|
||||
Tool::new(
|
||||
"send_run_input",
|
||||
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
|
||||
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
|
||||
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
|
||||
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let text = call.opt_str("text")?.unwrap_or_default();
|
||||
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
|
||||
if text.is_empty() && keys.is_empty() {
|
||||
return Err(IdeError::invalid("pass `text`, `keys` or both"));
|
||||
}
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
call.project().require_trust()?;
|
||||
let host = call.host();
|
||||
let mut data = text;
|
||||
for key in keys {
|
||||
match key {
|
||||
Key::Text(text) => data.push_str(text),
|
||||
Key::EndOfInput | Key::Interrupt => {
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
|
||||
}
|
||||
if key == Key::EndOfInput {
|
||||
crate::ops::run::close_stdin(&host, id)?;
|
||||
} else {
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &data)?;
|
||||
}
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("text", ParamType::String, "Text to type"))
|
||||
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
|
||||
}
|
||||
|
||||
pub fn stop_run() -> Tool {
|
||||
Tool::new(
|
||||
"stop_run",
|
||||
&format!(
|
||||
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
|
||||
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
|
||||
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
|
||||
keeps `stopping`: call again with `force`. The tab stays open with its output."
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
let force = call.flag("force", false)?;
|
||||
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
if force {
|
||||
// A second Stop kills the tree.
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
}
|
||||
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
|
||||
let mut extra = Map::new();
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
|
||||
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
|
||||
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
|
||||
assert!(key("F13").unwrap_err().message.contains("unknown key"));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 60_000;
|
||||
const MAX_TIMEOUT_MS: usize = 3_600_000;
|
||||
const MAX_PROBLEMS: usize = 300;
|
||||
/// Console lines attached to a failure whose messages were not parsed.
|
||||
const CONSOLE_LINES: usize = 200;
|
||||
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
|
||||
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
|
||||
modules containing those files. Asks the user first for every call. Problems have the project-relative \
|
||||
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
|
||||
is an ERROR. On timeout (0 times out at once) the build is stopped.",
|
||||
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|
||||
|call| async move {
|
||||
let files: Vec<String> =
|
||||
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
|
||||
let rebuild = call.flag("rebuild", false)? && files.is_empty();
|
||||
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
|
||||
let mut targets: Vec<Option<PathBuf>> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for f in &files {
|
||||
|
||||
@@ -19,6 +19,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// No limit in JetBrains; the process keeps running after the timeout anyway.
|
||||
const MAX_TIMEOUT_MS: usize = 1_800_000;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
|
||||
)?;
|
||||
let wait = call.flag("waitForExit", true)?;
|
||||
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
|
||||
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let configs = load_configs(&call).await?;
|
||||
let mut config = match &target {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Run configurations, builds and agent commands; every start asks the user first.
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
|
||||
pub(crate) mod agent;
|
||||
mod agent_tools;
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
let mut tools =
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
|
||||
tools.extend(agent_tools::tools());
|
||||
tools
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
}
|
||||
|
||||
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
|
||||
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
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. 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, max: usize) -> IdeResult<Duration> {
|
||||
Ok(match call.opt_i64("timeout")? {
|
||||
None => Duration::from_millis(default as u64),
|
||||
Some(n) if n <= 0 => Duration::ZERO,
|
||||
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -234,19 +234,7 @@ pub async fn start_request(
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let mut config = config;
|
||||
let agent = match host.project(window) {
|
||||
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
|
||||
Err(_) => None,
|
||||
};
|
||||
let on_output = match &agent {
|
||||
Some(agent) => agent.wrap_output(on_output),
|
||||
None => on_output,
|
||||
};
|
||||
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
|
||||
if let Some(agent) = &agent {
|
||||
agent.started(&result);
|
||||
}
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
@@ -261,7 +249,6 @@ async fn start_inner(
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
agent: Option<&crate::mcp::run::agent::Launch>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -295,9 +282,6 @@ async fn start_inner(
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
sink = agent.wrap_sink(sink);
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
host.mcp_agent_runs.stopping(id);
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
/// Input the user typed into a run (the agent's goes to the process directly).
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
host.mcp_agent_runs.user_input(id, data);
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -463,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
|
||||
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
|
||||
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.
|
||||
#[cfg(unix)]
|
||||
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
|
||||
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut handled = std::collections::HashSet::new();
|
||||
loop {
|
||||
let request = events.lock().unwrap().iter().find_map(|e| match e {
|
||||
IdeEvent::McpRequest {
|
||||
id,
|
||||
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
|
||||
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((id, name, command, cwd, env)) = request else {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
continue;
|
||||
};
|
||||
handled.insert(id);
|
||||
let mut config = ide_run::RunConfig {
|
||||
id: format!("mcp-agent:{name}"),
|
||||
name: name.clone(),
|
||||
kind: "shell".into(),
|
||||
command,
|
||||
args: Vec::new(),
|
||||
cwd: Some(cwd),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: true,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
|
||||
config.extras.target = Some(String::new());
|
||||
out.lock().unwrap().push(config.clone());
|
||||
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
|
||||
let error = result.err().map(|e| e.message);
|
||||
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
|
||||
}
|
||||
});
|
||||
started
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
|
||||
let id = format!("mcp-agent:{name}");
|
||||
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
|
||||
let h = Harness::new();
|
||||
h.trust().await;
|
||||
h.host.mcp_requests.set_ready("main", true);
|
||||
h.host
|
||||
.global_settings
|
||||
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
|
||||
.unwrap();
|
||||
let started = run_tool_window(&h);
|
||||
|
||||
// A command that finishes within the wait, in a tab named with the prefix.
|
||||
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
|
||||
assert_eq!(done["name"], "_agent_check", "{done}");
|
||||
assert_eq!(done["state"], "failed", "{done}");
|
||||
assert_eq!(done["exitCode"], 3);
|
||||
assert_eq!(done["startedBy"], "agent");
|
||||
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
|
||||
assert!(done.get("interventions").is_none());
|
||||
|
||||
// The finished name runs the next command; a running one is refused, never stopped.
|
||||
let running =
|
||||
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
|
||||
assert_eq!(running["state"], "running", "{running}");
|
||||
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
|
||||
assert_eq!(matched["waitEnded"], "matched", "{matched}");
|
||||
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
|
||||
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
|
||||
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
|
||||
|
||||
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
|
||||
let process = agent_process(&h, "_agent_check");
|
||||
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
|
||||
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
|
||||
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
|
||||
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
|
||||
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
|
||||
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
|
||||
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
|
||||
|
||||
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
|
||||
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
|
||||
assert!(stopped.get("interventions").is_none());
|
||||
|
||||
// The user's Stop, rerun and closing the tab are reported with their time.
|
||||
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
|
||||
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
|
||||
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
|
||||
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
|
||||
|
||||
let config = started.lock().unwrap().last().cloned().unwrap();
|
||||
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
|
||||
let status = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert_eq!(status["startedBy"], "user", "{status}");
|
||||
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
|
||||
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
|
||||
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
|
||||
let closed = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert!(closed["tabClosedAt"].is_string(), "{closed}");
|
||||
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
|
||||
|
||||
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
|
||||
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
|
||||
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
|
||||
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
|
||||
let runs = h.json("list_runs", json!({})).await;
|
||||
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
|
||||
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
|
||||
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
|
||||
|
||||
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
|
||||
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
|
||||
let lines =
|
||||
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
|
||||
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
|
||||
assert_eq!(lines["truncatedLines"], 1);
|
||||
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
|
||||
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
|
||||
|
||||
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
|
||||
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
|
||||
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
|
||||
assert!(key.contains("runs on pipes"), "{key}");
|
||||
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
|
||||
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
|
||||
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
|
||||
}
|
||||
@@ -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())]);
|
||||
|
||||
+22
-249
@@ -3,39 +3,17 @@
|
||||
use crate::discovery::Discovery;
|
||||
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Either, Full, Limited};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::{JoinError, JoinHandle};
|
||||
use tokio_util::sync::DropGuard;
|
||||
|
||||
/// Largest request body.
|
||||
const MAX_BODY: usize = 8 << 20;
|
||||
/// A call still running after this long is answered on an event stream (when the client
|
||||
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
|
||||
/// response has not started (Claude Code after a minute).
|
||||
#[cfg(not(test))]
|
||||
const STREAM_AFTER: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STREAM_AFTER: Duration = Duration::from_millis(100);
|
||||
/// How often a streamed call reports that it is still running: progress notifications reset
|
||||
/// the client's idle timeout (Claude Code's is five minutes).
|
||||
#[cfg(not(test))]
|
||||
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
#[cfg(test)]
|
||||
const HEARTBEAT: Duration = Duration::from_millis(100);
|
||||
|
||||
type Handled = Result<Option<Value>, Value>;
|
||||
type Body = Either<Full<Bytes>, EventStream>;
|
||||
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
@@ -162,11 +140,15 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
|
||||
async fn serve(
|
||||
req: Request<Incoming>,
|
||||
shared: Arc<Shared>,
|
||||
scope: String,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
Ok(respond(req, &shared, scope).await)
|
||||
}
|
||||
|
||||
type Reply = Response<Body>;
|
||||
type Reply = Response<Full<Bytes>>;
|
||||
|
||||
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
|
||||
if req.uri().path() != "/mcp" {
|
||||
@@ -181,7 +163,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
return response;
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
// No stream of server-initiated messages: every answer is in the POST's response.
|
||||
// No server-sent event streams: every answer is in the POST's response.
|
||||
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
|
||||
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||
return response;
|
||||
@@ -193,41 +175,25 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
|
||||
let project = project_of(&req);
|
||||
let streams =
|
||||
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
|
||||
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
|
||||
};
|
||||
let message = serde_json::from_slice::<Value>(&body).ok();
|
||||
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
|
||||
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
|
||||
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let response_scope = scope.clone();
|
||||
let registry = shared.registry.clone();
|
||||
let shared = shared.clone();
|
||||
let cancel = shared.cancel.child_token();
|
||||
// Cancels the call when the client goes away (the response is dropped).
|
||||
let guard = cancel.clone().drop_guard();
|
||||
let mut handled = tokio::task::spawn_blocking(move || {
|
||||
let _guard = cancel.clone().drop_guard();
|
||||
let handled = tokio::task::spawn_blocking(move || {
|
||||
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
|
||||
});
|
||||
if streams && !initializing {
|
||||
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
|
||||
Ok(done) => return answer(done, false, &response_scope),
|
||||
Err(_) => return event_stream(handled, id, progress_token, guard),
|
||||
}
|
||||
}
|
||||
let done = handled.await;
|
||||
drop(guard);
|
||||
answer(done, initializing, &response_scope)
|
||||
}
|
||||
|
||||
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
|
||||
})
|
||||
.await;
|
||||
match handled {
|
||||
Ok(Ok(Some(reply))) => {
|
||||
let mut response = json(StatusCode::OK, &reply);
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
|
||||
response.headers_mut().insert("mcp-session-id", value);
|
||||
}
|
||||
response
|
||||
@@ -238,71 +204,6 @@ fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a call that is still running on an event stream: a progress notification every
|
||||
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
|
||||
/// answer. The call is cancelled as soon as the client goes away.
|
||||
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut handled => {
|
||||
let reply = match done {
|
||||
Ok(Ok(Some(reply)) | Err(reply)) => reply,
|
||||
Ok(Ok(None)) => return,
|
||||
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
|
||||
};
|
||||
let _ = tx.send(event(&reply)).await;
|
||||
return;
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
let elapsed = started.elapsed();
|
||||
let still_running = match &token {
|
||||
Some(token) => event(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": token,
|
||||
"progress": elapsed.as_secs_f64(),
|
||||
"message": format!("running for {}s", elapsed.as_secs()),
|
||||
},
|
||||
})),
|
||||
None => Bytes::from_static(b": running\n\n"),
|
||||
};
|
||||
if tx.send(still_running).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut response = Response::new(Either::Right(EventStream(rx)));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
/// One server-sent event carrying a JSON-RPC message.
|
||||
fn event(message: &Value) -> Bytes {
|
||||
Bytes::from(format!("event: message\ndata: {message}\n\n"))
|
||||
}
|
||||
|
||||
/// A `text/event-stream` body, written by [`event_stream`]'s task.
|
||||
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
|
||||
|
||||
impl hyper::body::Body for EventStream {
|
||||
type Data = Bytes;
|
||||
type Error = Infallible;
|
||||
|
||||
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
|
||||
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
@@ -341,22 +242,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, text: &str) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
|
||||
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: &Value) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
|
||||
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
fn empty(status: StatusCode) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
|
||||
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
}
|
||||
@@ -364,134 +265,6 @@ fn empty(status: StatusCode) -> Reply {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
|
||||
struct Slow(Arc<AtomicBool>);
|
||||
|
||||
impl ToolRegistry for Slow {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/project")]
|
||||
}
|
||||
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
_: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, crate::ToolCallError> {
|
||||
if name == "wait" {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
return Err(crate::ToolCallError::Failed("cancelled".into()));
|
||||
}
|
||||
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
Ok("done".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> (McpServer, Arc<AtomicBool>) {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let registry = Arc::new(Slow(cancelled.clone()));
|
||||
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
|
||||
}
|
||||
|
||||
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
|
||||
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
|
||||
if let Some(token) = token {
|
||||
params["_meta"] = json!({ "progressToken": token });
|
||||
}
|
||||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
|
||||
}
|
||||
|
||||
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
|
||||
let body = body.to_string();
|
||||
let port = server.port();
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let request = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
server.token(),
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
/// The whole response, lowercased headers and the body as sent (chunked or not).
|
||||
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
|
||||
let (head, body) = out.split_once("\r\n\r\n").unwrap();
|
||||
(head.to_ascii_lowercase(), body.to_owned())
|
||||
}
|
||||
|
||||
fn events(body: &str) -> Vec<Value> {
|
||||
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
|
||||
}
|
||||
|
||||
const BOTH: &str = "application/json, text/event-stream";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
|
||||
let (server, _) = start();
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
|
||||
assert!(head.contains("content-type: text/event-stream"), "{head}");
|
||||
let messages = events(&body);
|
||||
let (answer, progress) = messages.split_last().unwrap();
|
||||
assert_eq!(answer["id"], 3);
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "done");
|
||||
assert!(progress.len() >= 2, "{body}");
|
||||
for pair in progress.windows(2) {
|
||||
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
|
||||
}
|
||||
for p in progress {
|
||||
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
|
||||
}
|
||||
|
||||
// Without a progress token, comments keep the stream going.
|
||||
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
|
||||
assert!(body.contains(": running"), "{body}");
|
||||
assert_eq!(events(&body).len(), 1, "{body}");
|
||||
|
||||
// A client that takes only JSON waits for it.
|
||||
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
|
||||
let (server, cancelled) = start();
|
||||
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
|
||||
let mut head = Vec::new();
|
||||
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(n > 0, "the stream ended early");
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
|
||||
drop(stream);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !cancelled.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the call was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
|
||||
+14
-106
@@ -35,8 +35,7 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
|
||||
}
|
||||
|
||||
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
|
||||
/// writes what it answers, if anything, one message per line to `output`: the
|
||||
/// answer, and before it the progress notifications of a long call.
|
||||
/// writes its answer, if any, as one line to `output`.
|
||||
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
@@ -45,37 +44,23 @@ where
|
||||
let scope = crate::server::new_token()?;
|
||||
let mut lines = input.lines();
|
||||
let mut pending = tokio::task::JoinSet::new();
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let mut eof = false;
|
||||
loop {
|
||||
if eof && pending.is_empty() && received.is_empty() {
|
||||
break;
|
||||
}
|
||||
let reply = tokio::select! {
|
||||
line = lines.next_line(), if !eof => {
|
||||
let Some(line) = line? else { eof = true; continue };
|
||||
if line.trim().is_empty() { continue; }
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => {
|
||||
let (discovery, project, scope, sent) =
|
||||
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
|
||||
pending.spawn(async move {
|
||||
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
|
||||
let _ = sent.send(reply);
|
||||
}
|
||||
});
|
||||
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
|
||||
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
|
||||
continue;
|
||||
}
|
||||
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
|
||||
}
|
||||
}
|
||||
Some(message) = received.recv() => Some(message),
|
||||
done = pending.join_next(), if !pending.is_empty() => {
|
||||
if let Some(Err(e)) = done {
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
|
||||
else => break,
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.to_string().as_bytes()).await?;
|
||||
@@ -87,24 +72,15 @@ where
|
||||
}
|
||||
|
||||
/// The IDE's answer to `message`, or an error response for each request in
|
||||
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
|
||||
/// with the messages before it.
|
||||
async fn relay(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope, sent).await {
|
||||
Ok(Answer::Streamed { answered: true }) => return None,
|
||||
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
|
||||
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
/// it when the IDE can't be reached.
|
||||
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope).await {
|
||||
Ok((StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(reply) => return Some(reply),
|
||||
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
|
||||
},
|
||||
Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Ok((status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
|
||||
@@ -124,23 +100,9 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Answer {
|
||||
Body(StatusCode, Bytes),
|
||||
/// The messages of an event stream went to the channel; `answered`: one was a response.
|
||||
Streamed {
|
||||
answered: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// POSTs `message` to the server in the discovery file; a message for the
|
||||
/// user when that fails.
|
||||
async fn post(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Result<Answer, String> {
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
@@ -163,60 +125,6 @@ async fn post(
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
let status = response.status();
|
||||
let streamed = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
|
||||
let mut body = response.into_body();
|
||||
if !streamed {
|
||||
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
return Ok(Answer::Body(status, body));
|
||||
}
|
||||
let (mut buffer, mut answered) = (Vec::new(), false);
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
|
||||
let Ok(data) = frame.into_data() else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
for data in take_events(&mut buffer) {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(&data) {
|
||||
answered |= message.get("method").is_none();
|
||||
let _ = sent.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Streamed { answered })
|
||||
}
|
||||
|
||||
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
|
||||
/// the rest. Comments and other fields are skipped.
|
||||
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
|
||||
let block: Vec<u8> = buffer.drain(..end + 2).collect();
|
||||
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
|
||||
let data: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|d| d.strip_prefix(' ').unwrap_or(d))
|
||||
.collect();
|
||||
if !data.is_empty() {
|
||||
events.push(data.join("\n"));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_events;
|
||||
|
||||
#[test]
|
||||
fn events_are_taken_whole_and_comments_skipped() {
|
||||
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
|
||||
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
|
||||
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
|
||||
buffer.extend_from_slice(b":2}\n\n");
|
||||
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
Ok((status, body))
|
||||
}
|
||||
@@ -39,10 +39,6 @@ impl ToolRegistry for Registry {
|
||||
match name {
|
||||
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
|
||||
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
|
||||
"sleep" => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
|
||||
Ok("slept".into())
|
||||
}
|
||||
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
|
||||
}
|
||||
}
|
||||
@@ -256,20 +252,6 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
|
||||
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
|
||||
let s = setup();
|
||||
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||||
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
|
||||
let mut output = Vec::new();
|
||||
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
|
||||
let replies: Vec<Value> =
|
||||
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let answer = replies.last().unwrap();
|
||||
assert_eq!(answer["id"], 4, "{replies:?}");
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "slept");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
|
||||
struct Many(Vec<PathBuf>);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ On your own messages you can **Edit** (the messages after it are replaced; files
|
||||
|
||||
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
|
||||
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and command permissions don't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and the Command Execution Policy doesn't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
|
||||
### Review the changes
|
||||
|
||||
|
||||
+2
-16
@@ -85,10 +85,8 @@ 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` |
|
||||
| Git | `get_repositories`, `git_status`, `git_diff` |
|
||||
|
||||
Notes on what they do:
|
||||
@@ -96,28 +94,16 @@ 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.
|
||||
|
||||
## Commands in the Run tool window
|
||||
|
||||
`run_command` runs a shell command line in a tab of the Run tool window, with the shell of **Settings › Tools › Terminal**. The agent names the tab and OpenIDEA shows it as `_agent_<name>`, so you see the output as it comes and can type into the command, rerun it or stop it like any run.
|
||||
|
||||
As JetBrains IDEs do, the command runs on pipes, not a terminal: programs print plain lines instead of progress bars and redraws. The agent gets that output cleaned of escape sequences and cut to 2000 lines (`maxLinesCount`); beyond that the end stays, or the start or both ends with `truncateMode`. The tab's raw output log stays complete.
|
||||
|
||||
- **One tab per name.** A name whose command finished runs the next command in the same tab. A name whose command is still running is refused, with that command and its last output: the agent has to stop it with `stop_run` first or pick another name. Nothing is stopped behind your back.
|
||||
- **Following a command up.** `wait_for_run` waits for the command to end or for a pattern in its output (a server's "listening" line), `read_run_output` reads the output from any point, `get_run_status` tells whether it is running, succeeded, failed, crashed or was stopped by the agent or by you, and `send_run_input` writes text and Enter to its input; Ctrl+C stops it and Ctrl+D ends its input.
|
||||
- **You can step in.** What you type into the tab's input line, a rerun, a Stop and closing the tab are reported to the agent with their time. A wait ends at once, and the agent is advised to stop what it is doing, tell you it received the intervention and wait for your confirmation.
|
||||
- **Finished tabs are greyed out.** Beyond **Finished agent tabs to show** (1 by default) the oldest finished ones move to an archive at the end of the tab strip, shown as **Archived (n)**. Beyond **Archived agent tabs to keep** (999) the oldest archived ones are closed. Running commands are never archived or closed. Both settings are on **Settings › Agents › IDE MCP Server**.
|
||||
|
||||
Plugins can add more tools; they appear in the same list. A plugin tool marked as destructive asks for confirmation the same way.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Untrusted projects** expose only the read-only tools. Tools that write files, rename, reformat, build or run need a project you have trusted.
|
||||
- **Running and building ask first.** `execute_run_configuration`, `build_project` and `run_command` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- **Running and building ask first.** `execute_run_configuration` and `build_project` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- Tools that write refuse paths outside the project.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
|
||||
### 查看改动
|
||||
|
||||
|
||||
+2
-16
@@ -84,10 +84,8 @@ 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` |
|
||||
| Git | `get_repositories`、`git_status`、`git_diff` |
|
||||
|
||||
几点说明:
|
||||
@@ -95,28 +93,16 @@ 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` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
|
||||
|
||||
## 在运行工具窗口中执行命令
|
||||
|
||||
`run_command` 在运行工具窗口的一个标签页中,用 **设置 › 工具 › 终端** 中配置的 shell 执行一条命令行。标签名由智能体指定,OpenIDEA 显示为 `_agent_<名称>`。输出实时可见,你也可以像对待其他运行一样向命令输入、重新运行或停止它。
|
||||
|
||||
和 JetBrains 的 IDE 一样,命令通过管道运行,而不是在终端里运行:程序输出的是普通文本行,不会有进度条和屏幕重绘。智能体拿到的输出会去掉控制字符,并截断到 2000 行(`maxLinesCount`);超出部分默认保留结尾,也可以用 `truncateMode` 改为保留开头或两头。标签页的原始输出日志保持完整。
|
||||
|
||||
- **一个名称对应一个标签。** 名称对应的命令已结束时,下一条命令在同一标签中运行。命令仍在运行时会被拒绝,并返回该命令和它最后的输出:智能体必须先用 `stop_run` 停止它,或者换一个名称。任何进程都不会被悄悄停掉。
|
||||
- **跟进命令。** `wait_for_run` 等待命令结束,或等待输出中出现指定文本(例如服务器的“正在监听”一行);`read_run_output` 从任意位置读取输出;`get_run_status` 报告命令是在运行、成功、失败、崩溃,还是被智能体或你停止;`send_run_input` 向命令的输入写入文本和 Enter,Ctrl+C 会停止命令,Ctrl+D 结束它的输入。
|
||||
- **你可以随时干预。** 你在标签输入行中输入的内容、重新运行、停止和关闭标签,都会连同时间一起告诉智能体。正在进行的等待会立即返回,并建议智能体停下手头的工作,回复你已收到干预,等你确认后再继续。
|
||||
- **已结束的标签会置灰。** 已结束的标签超过 **显示的已结束智能体标签数**(默认 1)时,最旧的会移入标签栏末尾的归档,显示为 **归档(n)**。归档超过 **保留的已归档智能体标签数**(默认 999)时,最旧的会被关闭。正在运行的命令永远不会被归档或关闭。这两项设置位于 **设置 › 智能体 › IDE MCP 服务器**。
|
||||
|
||||
插件可以添加更多工具,它们会出现在同一个列表中。标记为破坏性操作的插件工具同样会先请求确认。
|
||||
|
||||
## 安全
|
||||
|
||||
- **未受信任的项目** 只开放只读工具。写文件、重命名、重新格式化、构建或运行的工具都要求项目已被信任。
|
||||
- **运行和构建会先询问。** `execute_run_configuration`、`build_project` 和 `run_command` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- **运行和构建会先询问。** `execute_run_configuration` 和 `build_project` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- 会写入的工具拒绝项目之外的路径。
|
||||
|
||||
## 限制
|
||||
|
||||
+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>;
|
||||
}
|
||||
@@ -140,15 +140,6 @@ pub fn mcp_ui_ready(window: Window, state: State<'_, AppState>, ready: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user closed the Run tool window tab of an agent command (`run_command`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_agent_run_closed(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
state.host.mcp_agent_runs.closed(&project.root, &name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_respond(
|
||||
|
||||
@@ -74,7 +74,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
file_dialog::gtk_pick_paths,
|
||||
mcp::mcp_ui_ready,
|
||||
mcp::mcp_respond,
|
||||
mcp::mcp_agent_run_closed,
|
||||
mcp::mcp_server_status,
|
||||
mcp::mcp_set_open_editors,
|
||||
mcp::mcp_set_focused_editor,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+1
-14
@@ -17,8 +17,6 @@ export const commands = {
|
||||
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
|
||||
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
|
||||
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
|
||||
/** The user closed the Run tool window tab of an agent command (`run_command`). */
|
||||
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
|
||||
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
|
||||
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
|
||||
mcpSetFocusedEditor: (editor: {
|
||||
@@ -4932,8 +4930,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[];
|
||||
@@ -7193,10 +7189,6 @@ export type McpServerSettings = {
|
||||
* (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
*/
|
||||
runWithoutConfirmation?: boolean,
|
||||
/** Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived. */
|
||||
agentTabLimit?: number,
|
||||
/** Archived agent tabs kept; older ones are closed. */
|
||||
agentArchiveLimit?: number,
|
||||
};
|
||||
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
@@ -7218,12 +7210,7 @@ export type McpServerStatus = {
|
||||
/** Transport of an MCP server configured in Settings › Agents › MCP Servers. */
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
|
||||
/**
|
||||
* What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
* line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
* started or failed to.
|
||||
*/
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null } | { type: "agentRun"; name: string; command: string; cwd: IpcPath; env: { [key in string]: string } };
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null };
|
||||
|
||||
export type MemoryCategory =
|
||||
/** The backend and its WebView processes. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings › Tools › Agents › Command Permissions: whitelist (auto-approve)
|
||||
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
|
||||
* and blacklist (auto-reject) command patterns evaluated on agent permission requests.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -47,18 +47,18 @@ export function AgentPolicyPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.body} aria-label="Command Permissions">
|
||||
<section className={styles.body} aria-label="Command Execution Policy">
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
Allowed commands run without asking, with or without extra arguments. Blocked commands are always rejected,
|
||||
even if allowed, and support * and ? wildcards. Commands with substitutions, redirections or variable
|
||||
assignments always ask.
|
||||
Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands
|
||||
with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win
|
||||
over allowed ones; they support the * and ? wildcards.
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Allowed Commands</T>
|
||||
<T>Allowed Commands (Whitelist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.allowedCommands.map((pattern) => (
|
||||
@@ -80,7 +80,7 @@ export function AgentPolicyPage() {
|
||||
value={allowDraft}
|
||||
onChange={(e) => setAllowDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAllowed()}
|
||||
placeholder={tx("e.g. cargo test")}
|
||||
placeholder={tx("e.g. cargo test*")}
|
||||
aria-label="Allowed command pattern"
|
||||
/>
|
||||
<Button size="small" aria-label="Add allowed command" onClick={addAllowed}>
|
||||
@@ -90,7 +90,7 @@ export function AgentPolicyPage() {
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Blocked Commands</T>
|
||||
<T>Blocked Commands (Blacklist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.blockedCommands.map((pattern) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ function FormWrapper({ children }: { children: ReactNode }) {
|
||||
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
|
||||
}
|
||||
|
||||
describe("Command Permissions sub-page", () => {
|
||||
describe("Command Execution Policy sub-page", () => {
|
||||
it("registers under Agents group in settings", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
try {
|
||||
@@ -40,7 +40,7 @@ describe("Command Permissions sub-page", () => {
|
||||
const agentsGroup = tree.find((n) => n.title === AGENTS_GROUP);
|
||||
expect(agentsGroup).toBeDefined();
|
||||
const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE);
|
||||
expect(policyNode?.title).toBe("Command Permissions");
|
||||
expect(policyNode?.title).toBe("Command Execution Policy");
|
||||
// A project's settings file can't decide what agents run without asking.
|
||||
expect(policyNode?.page?.projectScope).not.toBe(true);
|
||||
} finally {
|
||||
@@ -56,7 +56,7 @@ describe("Command Permissions sub-page", () => {
|
||||
</FormWrapper>,
|
||||
);
|
||||
|
||||
const policy = screen.getByRole("region", { name: "Command Permissions" });
|
||||
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
|
||||
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function activateSettingsPages(): () => void {
|
||||
}),
|
||||
registerSettingsPage({
|
||||
id: POLICY_PAGE,
|
||||
title: "Command Permissions",
|
||||
title: "Command Execution Policy",
|
||||
group: AGENTS_GROUP,
|
||||
order: 490,
|
||||
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
|
||||
|
||||
@@ -13,7 +13,7 @@ export const REGISTRY_PAGE = "agents.list";
|
||||
export const AGENTS_GROUP = "Agents";
|
||||
/** Settings › Agents › External MCP Servers: User and Workspace MCP configurations. */
|
||||
export const MCP_PAGE = "agents.mcpServers";
|
||||
/** Settings › Agents › Command Permissions. */
|
||||
/** Settings › Agents › Command Execution Policy. */
|
||||
export const POLICY_PAGE = "agents.policy";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user