Compare commits

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

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

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

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

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

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:29:43 +08:00
GuoYuandguoyucode 14ee1a97f2 feat(mcp): agent commands in Run tool window tabs, with streamed progress and no timeout cap (#2997)
The MCP server let agents read and edit code but not run a command and follow it, so they ran commands in their own shell, out of the user's sight. New tools run_command, wait_for_run, read_run_output, get_run_status, send_run_input, stop_run and list_runs run each command in a Run tool window tab _agent_<name>: the user sees it and can type into it, rerun, stop or close it, and results then carry interventions and advice so the agent stops and asks. The same name reuses its tab; a running one is refused. Every start asks the user unless tools run without confirmation. Commands run on pipes, with maxLinesCount / truncateMode, offsets to read on, and pattern waits. Finished agent tabs beyond a limit (default 1) move to an Archived (n) menu.

A call still running after a second is answered on an event stream with a progress notification every 15 s, so clients that drop a response not started within a minute keep waiting; the run tools (build_project and execute_run_configuration included) no longer cap timeout. A call is cancelled when its client goes away; the stdio bridge relays streamed answers.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:23:56 +08:00
l0ng-ai 916c4243bf fix(agents): shorten the command permissions copy (#3072)
Settings › Agents › Command Execution Policy becomes Command Permissions, its description is split into short sentences, the columns drop the (Whitelist) / (Blacklist) suffixes, and the allowed-command placeholder no longer suggests a trailing `*` that allow entries ignore. Chinese: 命令权限, and Coding Agents reads 编程智能体 like its parent group.
2026-11-13 13:18:05 +08:00
l0ng-ai 9c63f174e4 fix(keymap): list the Git log's Show Diff in the VS Code preset (#3071)
#3065 bound vcs.log.changes.showDiff in the IntelliJ keymaps, and the VS Code
preset must name every IntelliJ action id (keymap.test.ts). VS Code has no Show
Diff key, so it gets none, like vcs.showDiff.
2026-11-13 13:13:44 +08:00
GuoYuandguoyucode b463252bc5 fix(run): decode Windows programs' piped output in any system language (#3060)
Commands run on pipes (Run configurations without terminal emulation, and agent commands) showed mojibake for Windows' own programs: ping, dir, cmd's messages and PowerShell's native output write a pipe in the console (OEM) code page, wsl.exe and cmd /u write UTF-16LE, while a UTF-8 console decoded every byte as UTF-8.

On Windows, a UTF-8 console now decodes piped output line by line: a UTF-8 line stays UTF-8, any other line is decoded in the console code page (encoding_rs for GBK, Big5, Shift_JIS, EUC-KR, 866, 874 and the 125x pages; Windows for the single-byte OEM pages it lacks), and a read with NUL bytes is UTF-16LE. ASCII passes through at once; the rest of a line is held until it shows its encoding, and a held prompt is shown after a batch interval without more output. An explicit console encoding and terminal emulation are unchanged; other platforms keep the plain UTF-8 decoder.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:06:11 +08:00
GuoYuandguoyucode 2abdac4fdb fix(mcp): a run point in a Cargo workspace member runs that member's tests (#3059)
execute_run_configuration with a run point in a Cargo workspace member ran the first member's tests: every member's `cargo test -p <member>` configuration runs from the workspace root, so all were equally close to the file and the first won. A Cargo configuration now covers its member's directory (extras.module) when the closest configuration is picked; its working directory stays the workspace root.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:04:35 +08:00
l0ng-ai 721f4144c1 fix(vcs): keep a commit's details shown while a refresh reloads them (#3070)
The Git log's commit details (containing branches, signature, refs) were keyed on
the repository's refresh version and dropped as soon as it changed, so each
refresh blanked the section until the reload finished. With something writing to
the repository's .git often, the section blinked continuously.

useCommitDetails now keeps the last details of the same commit until the reloaded
ones arrive; another commit still starts empty.
2026-11-13 13:01:09 +08:00
l0ng-ai 55057b8958 feat(vcs): open a file on its Git remote from Open In (#3069)
Open In in the project view and the editor (context menu and tab) now lists the
repository's hosting service, GitHub, GitLab, Bitbucket or Gitee, named after the
remote. It opens the file at the caret line, or the directory, on the remote's web
UI, at the current branch's upstream when it has one and at the HEAD commit
otherwise. The remote is the upstream's, else origin. Hosts other than the known
services are treated as self-managed GitLab and shown by host name.

The remote URL of each repository is read when the repositories or the upstream
change, so the menu is built without waiting for Git.
2026-11-13 12:57:02 +08:00
l0ng-ai 8abc8e7fbf fix(clipboard): read the clipboard natively for Paste from menus (#3068)
A Paste chosen from a menu (the terminal's context menu, Edit › Paste, Paste as
Plain Text and other paste buttons) has no paste event to read, so it used
navigator.clipboard.readText(), and the terminal tried execCommand("paste") first.
On Linux, WebKitGTK gives neither the text, so the terminal's context-menu Paste
did nothing while middle-click paste worked.

A new clipboard_text command reads the clipboard's text natively, as
clipboard_write_text already writes it: GTK on Linux, NSPasteboard on macOS, and
none on Windows, where the WebView's clipboard is still used.
readClipboardPlainText() wraps it with that fallback, and every menu paste reads
through it. On Linux the terminal pastes the native text before trying
execCommand; an image-only clipboard keeps the existing paths.
2026-11-13 12:52:26 +08:00
l0ng-ai 68dd949df3 fix(terminal): keep the first shifted character a macOS IME commits directly (#3067)
With WeChat or Pinyin on macOS, Shift+/ commits `?` with an insertText that
arrives before the character's keydown, while only Shift is down. xterm's input
gate was already armed by the Shift keydown and dropped the event, so the first
press produced nothing and the key had to be pressed twice.

The macOS input handler now also delivers an insertText that arrives while only
modifiers are down since the last keyup. Shifted keys xterm sends itself are
unchanged: their keydown comes first.
2026-11-13 12:41:17 +08:00
l0ng-ai b98bddbbab fix(vcs): fill the diff dialog with the editors (#3066)
The front diff dialog (the modal commit interface's Show Diff) left the area below a
short diff in the dialog's background: its body wrapper used flex: 1 inside the
dialog body, which is not a flex container, so it only took the content's height.
It now fills the body, and the editors' background reaches the footer.
2026-11-13 12:33:15 +08:00
l0ng-ai 21b05ab73a fix(vcs): show the commit's diff on ⌘D in the Git Log's changed files (#3065)
⌘D / Ctrl+D in the Git Log's changed files ran the global Show Diff, which diffs
the selected file's working copy against HEAD, and the context menu's Show Diff had
no shortcut. The changed files now set a vcsLogChangesFocus context; the log's Show
Diff is bound to the Show Diff key while it is set, so the menu shows it and the key
opens the commit's diff of the selected files, and the global Show Diff steps aside.
2026-11-13 12:31:56 +08:00
164 changed files with 5067 additions and 283 deletions

No files matched your search

+9
View File
@@ -151,6 +151,13 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -233,6 +240,8 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
];
const platforms = {};
for (const sig of sigs) {
+15
View File
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
#[cfg(target_os = "macos")]
pub fn text() -> Option<String> {
imp::text()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -373,6 +380,14 @@ mod imp {
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
+5
View File
@@ -8,13 +8,18 @@
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)]
+52 -1
View File
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text
}
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
@@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
})
}
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
pub fn console_code_page() -> Option<u32> {
#[cfg(windows)]
{
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
(cp != 65001).then_some(cp)
}
#[cfg(not(windows))]
{
None
}
}
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
/// piece by piece.
#[cfg(windows)]
pub fn is_single_byte_code_page(cp: u32) -> bool {
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
unsafe {
let mut info: CPINFO = std::mem::zeroed();
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
}
}
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
/// like the OEM pages 437, 850 or 852).
#[cfg(windows)]
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
use windows_sys::Win32::Globalization::MultiByteToWideChar;
let Ok(len) = i32::try_from(bytes.len()) else {
return String::from_utf8_lossy(bytes).into_owned();
};
if len == 0 {
return String::new();
}
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::from_utf8_lossy(bytes).into_owned();
}
let mut wide = vec![0u16; needed as usize];
// SAFETY: `wide` holds `needed` units.
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
String::from_utf16_lossy(&wide[..written.max(0) as usize])
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
+11 -1
View File
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, 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,6 +296,16 @@ 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.
+4
View File
@@ -60,6 +60,8 @@ 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,
@@ -141,6 +143,7 @@ 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")),
@@ -269,6 +272,7 @@ 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);
+176
View File
@@ -0,0 +1,176 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
fn problem(d: Diagnostic, text: &str) -> Value {
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+34 -9
View File
@@ -30,9 +30,14 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file.
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
The maximum accepted limit is 5000 lines.",
|call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
@@ -50,7 +55,7 @@ The maximum accepted limit is 5000 lines.",
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -70,10 +75,12 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
@@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String>
if available <= max_lines {
return Ok(range(start, count));
}
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
@@ -245,7 +261,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
render_slice(text, start, max, false).unwrap()
}
#[test]
@@ -254,18 +270,27 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_long_slices() {
fn keeps_head_and_tail_of_a_long_default_read() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+4 -1
View File
@@ -1,8 +1,9 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
mod run;
pub(crate) mod run;
mod vcs;
pub use requests::Requests;
mod files;
@@ -16,6 +17,7 @@ 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;
@@ -30,6 +32,7 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
+418
View File
@@ -0,0 +1,418 @@
//! `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"));
}
}
+2 -3
View File
@@ -15,7 +15,6 @@ 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;
@@ -84,13 +83,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 (at most 1 hour; 0 times out at once) the build is stopped.",
is an ERROR. On timeout (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, MAX_TIMEOUT_MS)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
+1 -3
View File
@@ -19,8 +19,6 @@ 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.
@@ -541,7 +539,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, MAX_TIMEOUT_MS)?) } else { None };
let limit = if wait { Some(timeout(&call, DEFAULT_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 {
+14 -5
View File
@@ -1,5 +1,7 @@
//! Run configurations and builds; every call asks the user first.
//! Run configurations, builds and agent commands; every start asks the user first.
pub(crate) mod agent;
mod agent_tools;
mod build;
mod configs;
mod output;
@@ -11,15 +13,22 @@ use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
let mut tools =
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
}
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// 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
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
})
}
+42 -2
View File
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned()
}
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
/// runs from the workspace root), else its working directory.
fn scope_of(root: &Path, c: &RunConfig) -> String {
match c.extras.module.as_deref() {
Some(module) if c.kind == "cargo" => {
let module = module.replace('\\', "/");
let module = module.strip_prefix("./").unwrap_or(&module);
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
}
_ => cwd_of(root, c),
}
}
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = cwd_of(root, c);
let cwd = scope_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
@@ -922,6 +935,33 @@ mod tests {
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn a_workspace_member_runs_its_own_tests() {
let root = Path::new("/p");
let member = |pkg: &str, dir: &str| {
let mut c = blank(
format!("cargo:test:{pkg}"),
"test",
"cargo",
"cargo".into(),
vec!["test".into(), "-p".into(), pkg.into()],
);
c.is_test = true;
c.extras.module = Some(dir.into());
c
};
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
workspace.is_test = true;
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let rel = "crates/ide-mcp/src/stdio.rs";
let ts = detect(src, rel);
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
+131 -2
View File
@@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -49,6 +57,8 @@ fn search_text() -> Tool {
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -57,6 +67,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -66,6 +77,8 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
@@ -75,6 +88,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -84,6 +98,7 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
@@ -99,6 +114,7 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -145,6 +161,13 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
impl SearchItem {
@@ -182,7 +205,9 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
},
"required": ["filePath"],
} },
@@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q)
}
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -416,6 +522,7 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
@@ -494,7 +601,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
let mut items: Vec<SearchItem> = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
@@ -726,6 +836,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -869,6 +987,17 @@ mod tests {
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
+2 -1
View File
@@ -313,10 +313,11 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
+20 -1
View File
@@ -234,7 +234,19 @@ 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 result = start_inner(host, window, config, on_output, &cancel).await;
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);
}
if let Some(id) = request {
host.end_request(id);
}
@@ -249,6 +261,7 @@ 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() {
@@ -282,6 +295,9 @@ 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"));
}
@@ -450,6 +466,7 @@ 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(),
@@ -461,7 +478,9 @@ 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);
}
+30 -2
View File
@@ -4125,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4143,7 +4148,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4315,10 +4320,33 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+173
View File
@@ -112,6 +112,16 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -453,3 +463,166 @@ 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}");
}
+116
View File
@@ -22,6 +22,9 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
/// syntax first, semantics later).
const CHECK_SETTLE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug)]
pub struct InspectOptions {
@@ -48,6 +51,29 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId);
}
/// One language server's part in a [`DocumentCheck`].
#[derive(Clone, Debug)]
pub struct CheckedServer {
pub name: String,
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
pub status: ide_core::event::ServerStatus,
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
pub message: Option<String>,
/// Its diagnostics are in the result.
pub checked: bool,
/// It was still analysing (or had not published for this change) when the wait ended.
pub incomplete: bool,
/// Asking it failed.
pub error: Option<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// What became of one file.
#[derive(Clone, Debug)]
pub enum FileInspection {
@@ -198,6 +224,96 @@ impl LspManager {
}
}
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
/// servers the document was saved, since checks that run on save (rust-analyzer's
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
/// to be ready, to answer for this text and to finish the background work it started, up
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
/// problems" from "nothing could check".
pub async fn check_document(
&self,
doc: DocId,
changed: Option<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
// Once the servers have the document: a save sent while one starts would not reach it.
self.did_save(doc, snap.text.as_str());
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
let mut report = CheckedServer {
name: server.name.clone(),
status: server.status(),
message: server.status_message(),
checked: false,
incomplete: false,
error: None,
};
let Some(run) = run else { return Some((report, Vec::new())) };
let t = Target { server: server.clone(), run };
if !t.caps().restriction.diagnostics() {
return None;
}
let pull = t.caps().has("diagnosticProvider");
let mut since = changed;
let raw = loop {
// A pull asks for this text; a push server is waited on to publish for it.
let raw = if pull {
server.wait_idle(left(), cancel).await;
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
} else {
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
wait_for_publish(server, &snap.path, since, opts, cancel).await
};
// The save may have started work (a `cargo check`) that publishes when it ends.
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
break raw;
}
if !server.wait_idle(left(), cancel).await {
break raw.map(|_| server.diagnostics_for(&snap.path));
}
// Idle: its last publish is final once quiet.
since = None;
};
report.status = server.status();
match raw {
Ok(items) => {
report.checked = true;
let fresh =
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
report.incomplete = !fresh || server.is_busy();
let conv = self.conv(t.enc());
let items = items
.into_iter()
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
.collect::<Vec<_>>();
Some((report, items))
}
Err(e) => {
report.error = Some(e.message);
Some((report, Vec::new()))
}
}
});
let results = tokio::select! {
r = futures::future::join_all(per_server) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let mut out = DocumentCheck::default();
for (report, items) in results.into_iter().flatten() {
out.servers.push(report);
out.diagnostics.extend(items);
}
Ok(out)
}
async fn pull_diagnostics(
&self,
t: &Target,
+1 -1
View File
@@ -40,7 +40,7 @@ mod vue_navigation;
mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
+18
View File
@@ -658,6 +658,24 @@ impl Server {
}
}
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
/// cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.phase_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`;
+51
View File
@@ -2178,6 +2178,57 @@ async fn missing_server_reports_once() {
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
let (server, uri) = (s.clone(), uri_of(&path));
let publisher = tokio::spawn(async move {
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
"severity": 1, "message": "on save" });
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
});
let cancel = CancellationToken::new();
let changed = Some(std::time::Instant::now());
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
publisher.await.unwrap();
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
// A change the server never answered: what it has is reported as not fresh.
let changed = Some(std::time::Instant::now());
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_reports_servers_that_cannot_check() {
let cancel = CancellationToken::new();
let h = harness(&[("ghost", json!({}))]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(check.diagnostics.is_empty());
let s = &check.servers[0];
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
assert_eq!(s.message.as_deref(), Some("install ghost"));
let h = harness(&[("mock", incremental_caps())]);
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Misconfigured).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
assert!(!check.servers[0].checked);
}
#[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]);
+249 -22
View File
@@ -3,17 +3,39 @@
use crate::discovery::Discovery;
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::Incoming;
use http_body_util::{BodyExt, Either, Full, Limited};
use hyper::body::{Frame, 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>,
@@ -140,15 +162,11 @@ 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<Full<Bytes>>, Infallible> {
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
Ok(respond(req, &shared, scope).await)
}
type Reply = Response<Full<Bytes>>;
type Reply = Response<Body>;
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
if req.uri().path() != "/mcp" {
@@ -163,7 +181,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
return response;
}
if req.method() != Method::POST {
// No server-sent event streams: every answer is in the POST's response.
// No stream of server-initiated messages: 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;
@@ -175,25 +193,41 @@ 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 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 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 response_scope = scope.clone();
let registry = shared.registry.clone();
let shared = shared.clone();
let cancel = shared.cancel.child_token();
let _guard = cancel.clone().drop_guard();
let handled = tokio::task::spawn_blocking(move || {
// 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 || {
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
})
.await;
});
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 {
match handled {
Ok(Ok(Some(reply))) => {
let mut response = json(StatusCode::OK, &reply);
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
response.headers_mut().insert("mcp-session-id", value);
}
response
@@ -204,6 +238,71 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
}
}
/// 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 {
@@ -242,22 +341,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
})
}
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
fn plain(status: StatusCode, text: &str) -> Reply {
let mut response = Response::new(Either::Left(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: &serde_json::Value) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
fn json(status: StatusCode, value: &Value) -> Reply {
let mut response = Response::new(Either::Left(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) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::new()));
fn empty(status: StatusCode) -> Reply {
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
*response.status_mut() = status;
response
}
@@ -265,6 +364,134 @@ fn empty(status: StatusCode) -> Response<Full<Bytes>> {
#[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() {
+106 -14
View File
@@ -35,7 +35,8 @@ 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 its answer, if any, as one line to `output`.
/// writes what it answers, if anything, one message per line to `output`: the
/// answer, and before it the progress notifications of a long call.
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
where
R: AsyncBufRead + Unpin,
@@ -44,23 +45,37 @@ 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) = (discovery.to_owned(), project.to_owned(), scope.clone());
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
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);
}
});
continue;
}
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
}
}
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
else => break,
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;
}
};
if let Some(reply) = reply {
output.write_all(reply.to_string().as_bytes()).await?;
@@ -72,15 +87,24 @@ where
}
/// The IDE's answer to `message`, or an error response for each request in
/// 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) {
/// 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) {
Ok(reply) => return Some(reply),
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
},
Ok((status, _)) => format!("OpenIDEA answered {status}"),
Ok(Answer::Body(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();
@@ -100,9 +124,23 @@ 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) -> Result<(StatusCode, Bytes), String> {
async fn post(
discovery: &Path,
project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Result<Answer, String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
@@ -125,6 +163,60 @@ async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) ->
.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 body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
Ok((status, body))
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());
}
}
+18
View File
@@ -39,6 +39,10 @@ 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:?}"))),
}
}
@@ -252,6 +256,20 @@ 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>);
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+12 -1
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2507,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
@@ -1 +1 @@
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
@@ -1 +1 @@
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
@@ -1 +1 @@
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
@@ -1 +1 @@
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
@@ -1 +1 @@
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
@@ -1 +1 @@
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
@@ -1 +1 @@
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -1 +1 @@
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
@@ -1 +1 @@
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
@@ -1 +1 @@
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
@@ -1 +1 @@
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
@@ -1 +1 @@
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
@@ -1 +1 @@
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
@@ -1 +1 @@
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
@@ -1 +1 @@
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
@@ -1 +1 @@
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
@@ -1 +1 @@
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
@@ -1 +1 @@
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
@@ -1 +1 @@
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
@@ -1 +1 @@
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
@@ -1 +1 @@
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
@@ -1 +1 @@
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
@@ -1 +1 @@
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
@@ -1 +1 @@
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
@@ -1 +1 @@
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
@@ -1 +1 @@
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
@@ -1 +1 @@
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
+50 -13
View File
@@ -498,7 +498,8 @@ impl ProcessManager {
None,
)?;
}
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
Ok(())
})();
if let Err(e) = result {
@@ -629,7 +630,10 @@ impl ProcessManager {
#[cfg(unix)]
Some(Box::new(wait_readable)),
)
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
.and_then(|_| {
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
});
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
@@ -1120,18 +1124,35 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
}
}
/// Appends what `decode` writes to `pending`, through the notice filter when there is one.
fn emit(
decode: impl FnOnce(&mut String),
notice: &mut Option<ide_core::output_encoding::NoticeFilter>,
decoded: &mut String,
pending: &mut String,
) {
match notice {
Some(filter) => {
decode(decoded);
filter.filter(decoded, pending);
decoded.clear();
}
None => decode(pending),
}
}
/// Merges both streams in arrival order, batching consecutive output of the same stream.
/// `decoders` decode stdout and stderr.
fn spawn_pump(
rx: mpsc::Receiver<Msg>,
on_output: OutputFn,
entry: Arc<Entry>,
sink: SharedSink,
encoding: Option<String>,
decoders: [OutputDecoder; 2],
notice: Option<String>,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
let [mut out_dec, mut err_dec] = decoders;
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
@@ -1140,6 +1161,8 @@ fn spawn_pump(
let mut pending = String::new();
let mut pending_stream = Stream::Stdout;
let mut batch_started: Option<Instant> = None;
// Since when a decoder holds part of a line back to tell its encoding.
let mut held_since: Option<Instant> = None;
let mut status: Option<Exit> = None;
let mut drain_deadline: Option<Instant> = None;
@@ -1154,6 +1177,7 @@ fn spawn_pump(
let now = Instant::now();
let wait = [
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
drain_deadline.map(|t| t.saturating_duration_since(now)),
]
.into_iter()
@@ -1170,14 +1194,8 @@ fn spawn_pump(
Stream::Stdout => (&mut out_dec, &mut out_notice),
Stream::Stderr => (&mut err_dec, &mut err_notice),
};
match notice {
Some(filter) => {
dec.decode_into(&bytes, &mut decoded);
filter.filter(&decoded, &mut pending);
decoded.clear();
}
None => dec.decode_into(&bytes, &mut pending),
}
emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending);
held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None };
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
@@ -1193,6 +1211,25 @@ fn spawn_pump(
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
// Nothing more came: show a held prompt.
if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
held_since = None;
for (stream, dec, notice) in [
(Stream::Stdout, &mut out_dec, &mut out_notice),
(Stream::Stderr, &mut err_dec, &mut err_notice),
] {
if dec.holds() {
if stream != pending_stream {
flush(&mut pending, pending_stream, &mut batch_started);
pending_stream = stream;
}
emit(|out| dec.release(out), notice, &mut decoded, &mut pending);
}
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
}
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
flush(&mut pending, pending_stream, &mut batch_started);
}
+390 -1
View File
@@ -136,6 +136,7 @@ mod tests {
pub enum OutputDecoder {
Utf8(Utf8Decoder),
Other(Box<encoding_rs::Decoder>),
Mixed(Box<MixedDecoder>),
}
impl OutputDecoder {
@@ -147,10 +148,36 @@ impl OutputDecoder {
}
}
/// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes
/// what Windows' own programs write: the lines that are not UTF-8 in the system's console
/// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16
/// (`wsl.exe`, `cmd /u`).
pub fn for_pipes(label: Option<&str>) -> Self {
match Self::for_label(label) {
Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new(
ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page),
))),
other => other,
}
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
match self {
Self::Utf8(d) => d.decode_into(bytes, out),
Self::Other(d) => decode_with(d, bytes, out, false),
Self::Mixed(d) => d.decode_into(bytes, out),
}
}
/// Whether part of a line is held back until it shows its encoding.
pub fn holds(&self) -> bool {
matches!(self, Self::Mixed(d) if d.holds())
}
/// Decodes the held part of a line now (no more output came for a while).
pub fn release(&mut self, out: &mut String) {
if let Self::Mixed(d) = self {
d.release(out);
}
}
@@ -162,10 +189,218 @@ impl OutputDecoder {
decode_with(d, &[], &mut out, true);
out
}
Self::Mixed(d) => d.finish(),
}
}
}
/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte
/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…).
pub enum NativeDecoder {
Encoding(Box<encoding_rs::Decoder>),
#[cfg(windows)]
SingleByte(u32),
}
impl NativeDecoder {
/// `None` for UTF-8 and for code pages neither can decode piece by piece.
pub fn for_code_page(cp: u32) -> Option<Self> {
if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) {
return (enc != encoding_rs::UTF_8)
.then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling())));
}
#[cfg(windows)]
if ide_core::output_encoding::is_single_byte_code_page(cp) {
return Some(Self::SingleByte(cp));
}
None
}
fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) {
match self {
Self::Encoding(d) => decode_with(d, bytes, out, last),
#[cfg(windows)]
Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Line {
/// Only ASCII so far, which reads the same in both.
Open,
Utf8,
Native,
}
/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo,
/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS,
/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command.
///
/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without
/// one). The part of a line after its first non-ASCII byte is held until the line ends or
/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes.
///
/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters
/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read
/// without NUL bytes comes.
pub struct MixedDecoder {
utf8: Utf8Decoder,
native: Option<NativeDecoder>,
line: Line,
held: Vec<u8>,
utf16: Option<Utf16>,
}
impl MixedDecoder {
pub fn new(native: Option<NativeDecoder>) -> Self {
Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None }
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
if bytes.is_empty() {
return;
}
let nul = bytes.contains(&0);
match self.utf16.as_ref().map(|u| u.open || nul) {
Some(true) => {
if let Some(u) = &mut self.utf16 {
u.decode(bytes, out);
}
return;
}
Some(false) => self.utf16 = None,
None if nul => {
self.end_line(out);
let mut u = Utf16::new();
u.decode(utf16_aligned(bytes), out);
self.utf16 = Some(u);
return;
}
None => {}
}
for piece in bytes.split_inclusive(|b| *b == b'\n') {
let ends = piece.last() == Some(&b'\n');
match self.line {
Line::Utf8 => self.utf8.decode_into(piece, out),
Line::Native => self.decode_native(piece, out),
Line::Open => {
let mut rest = piece;
if self.held.is_empty() {
let ascii = piece.iter().take_while(|b| b.is_ascii()).count();
out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default());
rest = &piece[ascii..];
}
self.held.extend_from_slice(rest);
let decided = match std::str::from_utf8(&self.held) {
Err(e) if e.error_len().is_some() => Some(Line::Native),
Ok(_) if ends => Some(Line::Utf8),
_ => None,
};
if let Some(line) = decided {
self.line = line;
self.flush_held(out);
}
}
}
if ends {
self.line = Line::Open;
}
}
}
fn flush_held(&mut self, out: &mut String) {
let held = std::mem::take(&mut self.held);
match self.line {
Line::Native => self.decode_native(&held, out),
Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out),
}
}
fn decode_native(&mut self, bytes: &[u8], out: &mut String) {
match &mut self.native {
Some(native) => native.decode_into(bytes, out, false),
None => self.utf8.decode_into(bytes, out),
}
}
/// Decodes the rest of the current line as it is.
fn end_line(&mut self, out: &mut String) {
if self.holds() {
self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native };
self.flush_held(out);
}
out.push_str(&self.utf8.finish());
self.line = Line::Open;
}
pub fn holds(&self) -> bool {
!self.held.is_empty()
}
/// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far.
pub fn release(&mut self, out: &mut String) {
if self.holds() {
self.line = Line::Utf8;
self.flush_held(out);
}
}
pub fn finish(&mut self) -> String {
let mut out = String::new();
if let Some(mut u) = self.utf16.take() {
decode_with(&mut u.decoder, &[], &mut out, true);
}
self.end_line(&mut out);
if let Some(native) = &mut self.native {
native.decode_into(&[], &mut out, true);
}
out
}
}
struct Utf16 {
decoder: encoding_rs::Decoder,
/// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds.
open: bool,
/// An odd number of bytes so far.
odd: bool,
}
impl Utf16 {
fn new() -> Self {
Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false }
}
fn decode(&mut self, bytes: &[u8], out: &mut String) {
let start = out.len();
decode_with(&mut self.decoder, bytes, out, false);
self.odd ^= bytes.len() % 2 == 1;
if self.odd {
self.open = true;
} else if out.len() > start {
self.open = !out.ends_with('\n');
}
}
}
/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of
/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a
/// character whose low byte came before (and was shown as ASCII).
fn utf16_aligned(bytes: &[u8]) -> &[u8] {
let (mut even, mut odd) = (0usize, 0usize);
for (i, b) in bytes.iter().enumerate() {
if *b == 0 {
if i % 2 == 0 {
even += 1;
} else {
odd += 1;
}
}
}
if even > odd { &bytes[1..] } else { bytes }
}
fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) {
let mut src = bytes;
loop {
@@ -180,7 +415,8 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
#[cfg(test)]
mod encoding_tests {
use super::OutputDecoder;
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
use ide_core::output_encoding::encoding_for_code_page;
#[test]
fn gbk_split_across_chunks() {
@@ -202,4 +438,157 @@ mod encoding_tests {
assert_eq!(out, "héllo");
assert!(matches!(d, OutputDecoder::Utf8(_)));
}
fn mixed(cp: u32) -> MixedDecoder {
MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page")))
}
fn encode(cp: u32, text: &str) -> Vec<u8> {
let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text);
assert!(!unmappable, "{text} in {cp}");
bytes.into_owned()
}
/// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western.
const SAMPLES: &[(u32, &str)] = &[
(936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"),
(950, "系統找不到指定的路徑。"),
(949, "지정된 경로를 찾을 수 없습니다."),
(932, "指定されたパスが見つかりません。"),
(866, "Системе не удается найти указанный путь."),
(1251, "Системе не удается найти указанный путь."),
(1252, "Le chemin d'accès spécifié est introuvable."),
];
#[test]
fn utf8_and_code_page_lines_mix_whatever_the_chunks() {
for &(cp, native) in SAMPLES {
let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec();
bytes.extend(encode(cp, native));
bytes.extend_from_slice(b"\r\n");
bytes.extend_from_slice("ok ✓\n".as_bytes());
bytes.extend(encode(cp, native));
let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}");
for split in 0..=bytes.len() {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(&bytes[..split], &mut out);
d.decode_into(&bytes[split..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, expected, "code page {cp}, split at {split}");
}
}
}
#[test]
fn byte_by_byte_code_page_output() {
for &(cp, native) in SAMPLES {
let bytes = [encode(cp, native), b"\n".to_vec()].concat();
let mut d = mixed(cp);
let mut out = String::new();
for b in &bytes {
d.decode_into(std::slice::from_ref(b), &mut out);
}
assert!(!d.holds());
assert_eq!(out, format!("{native}\n"), "code page {cp}");
}
}
#[test]
fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() {
let mut d = mixed(936);
let mut out = String::new();
d.decode_into(b"Password: ", &mut out);
assert_eq!(out, "Password: ");
assert!(!d.holds());
out.clear();
d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out);
assert_eq!(out, "请按任意键继续. . .");
assert!(!d.holds());
let mut d = mixed(936);
out.clear();
d.decode_into("Continue? 继续 ".as_bytes(), &mut out);
assert_eq!(out, "Continue? ");
assert!(d.holds());
d.release(&mut out);
assert_eq!(out, "Continue? 继续 ");
d.decode_into("[y/N]\n".as_bytes(), &mut out);
assert_eq!(out, "Continue? 继续 [y/N]\n");
}
#[test]
fn a_released_line_split_inside_a_character_completes() {
let mut d = mixed(949);
let mut out = String::new();
let bytes = "é中".as_bytes();
d.decode_into(&bytes[..3], &mut out);
d.release(&mut out);
d.decode_into(&bytes[3..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, "é中");
}
#[test]
fn utf8_code_page_has_no_mixed_decoder() {
assert!(NativeDecoder::for_code_page(65001).is_none());
}
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
/// What `wsl.exe -l -v` writes, on a Chinese system.
const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\
适用于 Linux 的 Windows 子系统: 默认\r\n";
#[test]
fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() {
let before = " Compiling 编译\n".as_bytes();
let wsl = utf16(WSL);
let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat();
let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n");
for split in 0..=wsl.len() {
let mut d = mixed(936);
let mut out = String::new();
for chunk in [before, &wsl[..split], &wsl[split..], &after] {
d.decode_into(chunk, &mut out);
}
out.push_str(&d.finish());
assert_eq!(out, expected, "split at {split}");
}
}
#[test]
fn utf16_with_a_byte_order_mark_or_starting_with_cjk() {
let mut d = mixed(949);
let mut out = String::new();
d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out);
d.decode_into(&utf16("中文 OK\r\n"), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "한국어 OK\r\n中文 OK\r\n");
}
#[test]
fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() {
let mut d = MixedDecoder::new(None);
let mut out = String::new();
d.decode_into(b"caf\xe9\n", &mut out);
d.decode_into(&utf16("wsl 子系统\r\n"), &mut out);
d.decode_into("ok ✓\n".as_bytes(), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n");
}
#[cfg(windows)]
#[test]
fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() {
for cp in [437, 850, 852] {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(b"caf\x82\r\n", &mut out);
assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}");
}
}
}
+69
View File
@@ -0,0 +1,69 @@
#![cfg(windows)]
use ide_core::{IdeEvent, SharedSink};
use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console.
fn run(command: &str) -> String {
let (tx, events) = mpsc::channel();
let tx = Mutex::new(tx);
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
let _ = tx.lock().send(e);
});
let root = tempfile::tempdir().unwrap();
let config = RunConfig {
id: "t:out".into(),
name: "t:out".into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let chunks: Arc<Mutex<Vec<OutputChunk>>> = Arc::default();
let out = chunks.clone();
let pm = ProcessManager::new();
let info = pm
.start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c)))
.expect("start");
loop {
match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") {
IdeEvent::RunExited { run, .. } if run == info.id => break,
_ => {}
}
}
chunks
.lock()
.iter()
.filter_map(|c| match c {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR,
/// 437…, by the system's language), and still read right in a UTF-8 console.
#[test]
fn native_messages_in_the_console_code_page_are_decoded() {
let text = run(r"dir C:\openidea-no-such-dir");
assert!(text.trim().lines().count() >= 2, "{text:?}");
assert!(!text.contains('\u{FFFD}'), "{text:?}");
}
/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language.
#[test]
fn utf16_output_is_decoded() {
let text = run("cmd /u /c echo 中文 한국어 OK");
assert_eq!(text, "中文 한국어 OK\r\n");
}
-2
View File
@@ -19,8 +19,6 @@ tar = "0.4"
zip.workspace = true
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dev-dependencies]
+2
View File
@@ -20,6 +20,8 @@ mod bundled;
pub mod formatter;
pub mod jdk;
pub mod platform;
#[cfg(any(windows, test))]
pub mod portable_update;
mod process;
pub mod registry;
pub mod server;
+223
View File
@@ -0,0 +1,223 @@
//! In-place updates of the Windows unpack-and-run distribution.
use ide_core::{IdeError, IdeResult};
use std::fs;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
/// `bytes` must already have passed the updater's signature verification.
pub fn install(exe: &Path, bytes: &[u8]) -> IdeResult<()> {
let directory = exe.parent().ok_or_else(|| IdeError::invalid("executable has no parent directory"))?;
let staging = tempfile::Builder::new().prefix(".openidea-update-").tempdir_in(directory)?;
let files = unpack(bytes, staging.path(), exe)?;
let mut replaced = Vec::new();
for (source, target) in files {
match replace_file(&source, &target) {
Ok(backup) => replaced.push((target, backup)),
Err(error) => {
for (target, backup) in replaced.into_iter().rev() {
fs::remove_file(&target)?;
if let Some(backup) = backup {
fs::rename(&backup, &target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
}
return Err(error);
}
}
}
Ok(())
}
/// Old executables stay locked until their process exits; retry cleanup on the next start.
pub fn cleanup(exe: &Path) {
for target in [exe.to_path_buf(), exe.with_file_name("WebView2Loader.dll")] {
let _ = fs::remove_file(backup_path(&target));
}
}
fn unpack(bytes: &[u8], staging: &Path, exe: &Path) -> IdeResult<Vec<(PathBuf, PathBuf)>> {
let invalid = |error| IdeError::invalid(format!("portable update archive: {error}"));
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut files = Vec::new();
let mut has_exe = false;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(invalid)?;
let name = entry.name().map_err(invalid)?;
let target = match name.as_ref() {
"openidea.exe" => {
has_exe = true;
exe.to_path_buf()
}
"WebView2Loader.dll" => exe.with_file_name("WebView2Loader.dll"),
name => return Err(IdeError::invalid(format!("unexpected portable update file: {name}"))),
};
if !entry.is_file() || entry.is_symlink() || files.iter().any(|(_, path)| path == &target) {
return Err(IdeError::invalid("portable update contains a duplicate or non-regular file"));
}
let source = staging.join(name.as_ref());
let mut file = fs::File::create(&source)?;
io::copy(&mut entry, &mut file)?;
file.sync_all()?;
files.push((source, target));
}
if !has_exe {
return Err(IdeError::invalid("portable update is missing openidea.exe"));
}
Ok(files)
}
fn replace_file(source: &Path, target: &Path) -> IdeResult<Option<PathBuf>> {
let backup = if target.try_exists()? {
if !target.is_file() {
return Err(IdeError::invalid(format!("not an application file: {}", target.display())));
}
let backup = backup_path(target);
if backup.try_exists()? {
fs::remove_file(&backup)?;
}
// Windows forbids overwriting a running exe, but allows renaming it.
fs::rename(target, &backup)?;
Some(backup)
} else {
None
};
if let Err(error) = fs::rename(source, target) {
if let Some(backup) = &backup {
fs::rename(backup, target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
return Err(error.into());
}
Ok(backup)
}
fn backup_path(target: &Path) -> PathBuf {
let mut path = target.as_os_str().to_os_string();
path.push(".openidea-old");
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn package(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn replaces_application_files_and_preserves_configuration() {
for name in ["openidea.exe", "我的 IDE.exe"] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join(name);
fs::write(&exe, b"old exe").unwrap();
fs::write(tmp.path().join("WebView2Loader.dll"), b"old dll").unwrap();
fs::write(tmp.path().join("settings.toml"), b"my settings").unwrap();
fs::create_dir(tmp.path().join("data")).unwrap();
fs::write(tmp.path().join("data/plugins.json"), b"my plugins").unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
install(&exe, &bytes).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(tmp.path().join("WebView2Loader.dll")).unwrap(), b"new dll");
assert_eq!(fs::read(tmp.path().join("settings.toml")).unwrap(), b"my settings");
assert_eq!(fs::read(tmp.path().join("data/plugins.json")).unwrap(), b"my plugins");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
assert!(!backup_path(&tmp.path().join("WebView2Loader.dll")).exists());
}
}
#[test]
fn accepts_an_exe_only_package() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
install(&exe, &package(&[("openidea.exe", b"new")])).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new");
}
#[test]
fn rejects_incomplete_or_unexpected_payloads_before_changing_files() {
for files in [
vec![("WebView2Loader.dll", b"new".as_slice())],
vec![("openidea.exe", b"new"), ("settings.toml", b"overwrite")],
vec![("openidea.exe", b"new"), ("../outside.txt", b"escape")],
] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
assert!(install(&exe, &package(&files)).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
}
}
#[cfg(windows)]
#[test]
fn updates_a_running_executable_and_restores_on_a_locked_companion() {
use std::os::windows::fs::OpenOptionsExt;
use std::process::{Command, Stdio};
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let ping = Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/PING.EXE");
fs::copy(ping, &exe).unwrap();
let original = fs::read(&exe).unwrap();
let dll = tmp.path().join("WebView2Loader.dll");
fs::write(&dll, b"old dll").unwrap();
let mut command = Command::new(&exe);
command.args(["-n", "30", "127.0.0.1"]).stdout(Stdio::null()).stderr(Stdio::null());
ide_core::process::prepare(&mut command);
let mut child = command.spawn().unwrap();
let result = {
assert!(fs::write(&exe, b"overwrite").is_err(), "fixture must hold the running exe");
let held = fs::OpenOptions::new().read(true).share_mode(1).open(&dll).unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
assert!(install(&exe, &bytes).is_err());
assert_eq!(fs::read(&exe).unwrap(), original);
assert_eq!(fs::read(&dll).unwrap(), b"old dll");
drop(held);
install(&exe, &bytes)
};
let _ = child.kill();
let _ = child.wait();
result.unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(&dll).unwrap(), b"new dll");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
}
#[cfg(windows)]
#[test]
fn restores_the_old_file_when_the_new_file_cannot_be_moved() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let source = tmp.path().join("new.exe");
fs::write(&exe, b"old").unwrap();
fs::write(&source, b"new").unwrap();
let _held = fs::OpenOptions::new().read(true).share_mode(1).open(&source).unwrap();
assert!(replace_file(&source, &exe).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
assert_eq!(fs::read(&source).unwrap(), b"new");
assert!(!backup_path(&exe).exists());
}
}
+1 -1
View File
@@ -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 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.
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.
### Review the changes
+16 -2
View File
@@ -85,8 +85,10 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
| Search | `search_text`, `search_regex`, `search_symbol`, `search_file` |
| Editors | `get_all_open_file_paths`, `get_current_file`, `open_file_in_editor` |
| Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` |
| Problems | `get_file_problems`, `get_project_problems` |
| Problems | `get_file_problems`, `get_project_problems`, `check_files` |
| Run and build | `get_run_configurations`, `execute_run_configuration`, `build_project` |
| Commands | `run_command`, `wait_for_run`, `send_run_input`, `stop_run` |
| Command state | `get_run_status`, `read_run_output`, `list_runs` |
| Git | `get_repositories`, `git_status`, `git_diff` |
Notes on what they do:
@@ -94,16 +96,28 @@ 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` 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.
- **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.
- Tools that write refuse paths outside the project.
## Limits
+1 -1
View File
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
### 查看改动
+16 -2
View File
@@ -84,8 +84,10 @@ stdio 配置大致如下(路径以你机器上的为准):
| 搜索 | `search_text`、`search_regex`、`search_symbol`、`search_file` |
| 编辑器 | `get_all_open_file_paths`、`get_current_file`、`open_file_in_editor` |
| 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` |
| 问题 | `get_file_problems`、`get_project_problems` |
| 问题 | `get_file_problems`、`get_project_problems`、`check_files` |
| 运行与构建 | `get_run_configurations`、`execute_run_configuration`、`build_project` |
| 命令 | `run_command`、`wait_for_run`、`send_run_input`、`stop_run` |
| 命令状态 | `get_run_status`、`read_run_output`、`list_runs` |
| Git | `get_repositories`、`git_status`、`git_diff` |
几点说明:
@@ -93,16 +95,28 @@ 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` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
- **运行和构建会先询问。** `execute_run_configuration`、`build_project` 和 `run_command` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
- 会写入的工具拒绝项目之外的路径。
## 限制
+5 -2
View File
@@ -598,6 +598,7 @@ then run `openidea-plugin check`.
| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. |
| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. |
| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. |
| `host::set_file_note` | `decorations.set-file-note` | API 1.7: a note after the name in the project tree (one line, at most 200 characters). Requires `project.read`. |
| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. |
| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. |
| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. |
@@ -851,8 +852,10 @@ Decorations are plain text, with fixed styles mapped to theme tokens:
`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`,
`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items
have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`,
where a badge has at most two Unicode characters. Paths must resolve inside an
open project; absolute paths select that project, relative paths use the invoking
where a badge has at most two Unicode characters. A tree item can instead be a note
(`set-file-note`, API 1.7): muted text after the file or folder name, one line of at
most 200 characters, with an optional tooltip; it replaces that set's item for the file.
Paths must resolve inside an open project; absolute paths select that project, relative paths use the invoking
project. Sets are isolated by plugin and project and shared by all windows of
that project. Setting replaces a set's items for one file; clearing removes that
set in all projects. Each plugin has at most 50 editor sets and 50 tree sets,
+1 -1
View File
@@ -1 +1 @@
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
@@ -1 +1 @@
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
+1 -1
View File
@@ -1 +1 @@
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
+1 -1
View File
@@ -1 +1 @@
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
+1 -1
View File
@@ -1 +1 @@
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
+1 -1
View File
@@ -1 +1 @@
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+1 -1
View File
@@ -1 +1 @@
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
+1 -1
View File
@@ -1 +1 @@
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
+1 -1
View File
@@ -1 +1 @@
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
+1 -1
View File
@@ -1 +1 @@
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
+1 -1
View File
@@ -1 +1 @@
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
+1 -1
View File
@@ -1 +1 @@
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
+1 -1
View File
@@ -1 +1 @@
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
+1 -1
View File
@@ -1 +1 @@
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
+1 -1
View File
@@ -1 +1 @@
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
+16
View File
@@ -11,6 +11,14 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${gh}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${gh}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
};
@@ -21,6 +29,14 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${cnb}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${cnb}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
});
});
+77
View File
@@ -0,0 +1,77 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { afterEach, expect, it, vi } from "vitest";
const workflow = readFileSync(new URL("../.github/workflows/release-build.yml", import.meta.url), "utf8").replaceAll(
"\r\n",
"\n",
);
const job = workflow.split("\n updater-manifest:")[1]!;
const script = job.split(" script: |\n")[1]!.replace(/^ {12}/gm, "");
const run = new Function("github", "context", "core", `return (async () => {${script}\n})();`);
afterEach(() => vi.unstubAllEnvs());
it.each(["v0.1.42", "v0.1.43-nightly.20261112"])(
"publishes signed portable updates for both Windows architectures alongside the installers (%s)",
async (tag) => {
vi.stubEnv("TAG", tag);
vi.stubEnv("RELEASE_ID", "42");
const version = tag.slice(1);
const nightly = tag.includes("-nightly.");
const bundles = [
"OpenIDEA_aarch64.AppImage",
"OpenIDEA_arm64.deb",
"OpenIDEA.aarch64.rpm",
"OpenIDEA_amd64.AppImage",
"OpenIDEA_amd64.deb",
"OpenIDEA.x86_64.rpm",
"OpenIDEA_aarch64.app.tar.gz",
"OpenIDEA_x64.app.tar.gz",
"OpenIDEA_arm64_en-US.msi",
"OpenIDEA_arm64-setup.exe",
"OpenIDEA_x64_en-US.msi",
"OpenIDEA_x64-setup.exe",
`OpenIDEA_${version}_windows_x64_portable.zip`,
`OpenIDEA_${version}_windows_arm64_portable.zip`,
].filter((name) => !nightly || (!name.endsWith(".msi") && !name.endsWith(".rpm")));
const assets = bundles.flatMap((name, id) => [
{ name, id: id * 2 },
{ name: `${name}.sig`, id: id * 2 + 1 },
]);
const upload = vi.fn();
const failed = vi.fn();
await run(
{
paginate: async () => assets,
rest: {
repos: {
listReleaseAssets: vi.fn(),
getRelease: async () => ({ data: { body: "Release notes" } }),
getReleaseAsset: async ({ asset_id }: { asset_id: number }) => ({
data: Buffer.from(`signature-${asset_id}\n`),
}),
uploadReleaseAsset: upload,
},
},
},
{ repo: { owner: "l0ng-ai", repo: "openidea" } },
{ notice: vi.fn(), info: vi.fn(), setFailed: failed },
);
expect(failed).not.toHaveBeenCalled();
const manifest = JSON.parse(upload.mock.calls[0]![0].data);
for (const [arch, filename] of [
["x86_64", "x64"],
["aarch64", "arm64"],
]) {
const name = `OpenIDEA_${version}_windows_${filename}_portable.zip`;
const sig = assets.find((a) => a.name === `${name}.sig`)!;
expect(manifest.platforms[`windows-${arch}-portable`]).toEqual({
signature: `signature-${sig.id}`,
url: `https://github.com/l0ng-ai/openidea/releases/download/${tag}/${name}`,
});
expect(manifest.platforms[`windows-${arch}`].url).toContain(nightly ? "-setup.exe" : "_en-US.msi");
expect(manifest.platforms[`windows-${arch}-nsis`].url).toContain("-setup.exe");
}
},
);
+8
View File
@@ -416,6 +416,9 @@ mod imp {
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
wit::decorations::clear_file(set).map_err(error)
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
wit::decorations::set_file_note(path, set, note, tooltip).map_err(error)
}
pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> {
wit::vcs::log(p, path, limit)
.map(|v| {
@@ -1842,6 +1845,11 @@ pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Resu
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
imp::clear_file_decorations(set)
}
/// Set a note shown after a file's name in the project tree, replacing this tree set's
/// annotation for the file (API 1.7). One line of at most 200 characters. Requires project.read.
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
imp::set_file_note(path, set, note, tooltip)
}
/// Starts an owned run configuration in the invoking window. Its id receives run-exited.
pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> {
+30 -2
View File
@@ -156,6 +156,7 @@ struct State {
restricted: bool,
decorations: BTreeMap<(String, String), Vec<Decoration>>,
file_decorations: BTreeMap<(String, String), FileDecoration>,
file_notes: BTreeMap<(String, String), (String, Option<String>)>,
revealed_ranges: Vec<Range>,
loopback_queries: VecDeque<Option<String>>,
net: Option<NetHandler>,
@@ -300,6 +301,10 @@ impl MockHost {
pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> {
self.state.borrow().file_decorations.clone()
}
/// `(set, path)` → `(note, tooltip)` set with `set_file_note`.
pub fn file_notes(&self) -> BTreeMap<(String, String), (String, Option<String>)> {
self.state.borrow().file_notes.clone()
}
/// Ranges requested to scroll into view.
pub fn revealed_ranges(&self) -> Vec<Range> {
self.state.borrow().revealed_ranges.clone()
@@ -1655,9 +1660,9 @@ pub mod current {
let mut next = s.file_decorations.clone();
next.remove(&(set.into(), path.clone()));
if style(&item.style) {
next.insert((set.into(), path), item.clone());
next.insert((set.into(), path.clone()), item.clone());
}
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1666,6 +1671,7 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1673,6 +1679,28 @@ pub mod current {
with(|s| {
require(s, "project.read")?;
s.file_decorations.retain(|(id, _), _| id != set);
s.file_notes.retain(|(id, _), _| id != set);
Ok(())
})
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
let path = decoration_path(s, path)?;
let key = (set.to_string(), path);
let names: BTreeSet<_> =
s.file_decorations.keys().chain(s.file_notes.keys()).chain([&key]).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
|| note.trim().is_empty()
|| note.chars().count() > 200
|| note.contains(['\n', '\r'])
{
return Err(Error::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
s.file_decorations.remove(&key);
s.file_notes.insert(key, (note.into(), tooltip.map(Into::into)));
Ok(())
})
}
@@ -67,6 +67,14 @@ fn decorations_replace_clear_validate_styles_badges_paths_and_limits() {
host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap();
host::clear_file_decorations("test").unwrap();
assert!(mock.file_decorations().is_empty());
host::set_file_decoration("a.txt", "notes", &file("A")).unwrap();
host::set_file_note("a.txt", "notes", "entry point", Some("why")).unwrap();
assert!(mock.file_decorations().is_empty(), "a note replaces the set's annotation");
assert_eq!(mock.file_notes().values().next(), Some(&("entry point".to_string(), Some("why".to_string()))));
assert!(host::set_file_note("a.txt", "notes", "two\nlines", None).is_err());
assert!(host::set_file_note("a.txt", "notes", &"x".repeat(201), None).is_err());
host::clear_file_decorations("notes").unwrap();
assert!(mock.file_notes().is_empty());
for i in 0..50 {
host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap();
}
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
+31
View File
@@ -22,6 +22,37 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
}
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
#[cfg(target_os = "linux")]
{
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let sender = std::cell::Cell::new(Some(tx));
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).request_text(move |_, text| {
if let Some(sender) = sender.take() {
let _ = sender.send(text.map(|t| t.to_string()));
}
});
})
.map_err(|error| IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")))?;
Ok(rx.await.ok().flatten())
}
#[cfg(target_os = "macos")]
{
with_clipboard(app, || Ok(ide_clipboard::text())).await
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = app;
Ok(None)
}
}
/// Absolute paths on the clipboard, empty when it holds no file list.
#[tauri::command]
#[specta::specta]
+9
View File
@@ -140,6 +140,15 @@ 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(
+2
View File
@@ -74,6 +74,7 @@ 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,
@@ -187,6 +188,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
fs::fs_read_text,
fs::fs_open_with_system,
clipboard::clipboard_write_text,
clipboard::clipboard_text,
clipboard::clipboard_file_list,
clipboard::clipboard_has_files,
clipboard::clipboard_image,
+19 -5
View File
@@ -464,7 +464,15 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
.collect::<IdeResult<Vec<_>>>()?;
let proxy = ide_core::proxy_settings::proxy();
app.updater_builder()
let mut builder = app.updater_builder();
if let Some(target) = crate::updates::portable_target(
std::env::consts::OS,
std::env::consts::ARCH,
tauri::utils::platform::bundle_type(),
) {
builder = builder.target(target);
}
builder
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
@@ -611,10 +619,16 @@ pub async fn app_install_update(
_ = token.cancelled() => return Err(IdeError::cancelled()),
};
let _ = on_progress.send(UpdateProgress::Installing);
tauri::async_runtime::spawn_blocking(move || update.install(bytes))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::internal(e.to_string()))
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
if update.target.ends_with("-portable") {
let exe = tauri::utils::platform::current_exe()?;
return ide_tools::portable_update::install(&exe, &bytes);
}
update.install(bytes).map_err(|e| IdeError::internal(e.to_string()))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
.await;
state.host.end_request(request);
+6
View File
@@ -176,6 +176,12 @@ pub fn run() {
})
.setup(move |app| {
builder.mount_events(app);
#[cfg(windows)]
tauri::async_runtime::spawn_blocking(|| {
if let Ok(exe) = tauri::utils::platform::current_exe() {
ide_tools::portable_update::cleanup(&exe);
}
});
// An unusable directory variable stops the start with a message, never a silent fallback.
let identifier = app.config().identifier.clone();
let local_data_dir = local_data_dir.and_then(|dir| {
+19
View File
@@ -11,6 +11,11 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tokio::sync::Notify;
/// Tauri patches the installer copies; the raw exe shipped in the portable zip keeps no bundle type.
pub fn portable_target(os: &str, arch: &str, bundle: Option<tauri::utils::config::BundleType>) -> Option<String> {
(os == "windows" && bundle.is_none()).then(|| format!("windows-{arch}-portable"))
}
#[derive(Default)]
pub struct Updates {
started: AtomicBool,
@@ -163,6 +168,20 @@ fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet<String>
mod tests {
use super::*;
#[test]
fn only_unbundled_windows_builds_select_portable_updates() {
use tauri::utils::config::BundleType;
for arch in ["x86_64", "aarch64"] {
assert_eq!(portable_target("windows", arch, None), Some(format!("windows-{arch}-portable")));
for bundle in [BundleType::Msi, BundleType::Nsis] {
assert_eq!(portable_target("windows", arch, Some(bundle)), None);
}
for os in ["linux", "macos"] {
assert_eq!(portable_target(os, arch, None), None);
}
}
}
#[test]
fn notifications_only_target_live_owners_that_have_subscribed() {
let live = vec!["main".into(), "project-2".into()];
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost; frame-ancestors 'none'"
}
},
"bundle": {
+20 -1
View File
@@ -17,6 +17,8 @@ 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: {
@@ -280,6 +282,12 @@ export const commands = {
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
/** Writes text locally, without WebView user-activation requirements. */
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
/**
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
* text there (#3041). None on Windows, where the WebView's clipboard is used.
*/
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
/** Absolute paths on the clipboard, empty when it holds no file list. */
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
@@ -4924,6 +4932,8 @@ export type FileDecoration = {
badge: string | null,
style: string,
tooltip: string | null,
/** Text shown after the name (API 1.7 `set-file-note`). */
note?: string | null,
};
export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[];
@@ -7183,6 +7193,10 @@ 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. */
@@ -7204,7 +7218,12 @@ export type McpServerStatus = {
/** Transport of an MCP server configured in Settings › Agents › MCP Servers. */
export type McpTransport = "stdio" | "http" | "sse";
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 };
/**
* 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 MemoryCategory =
/** The backend and its WebView processes. */
+3 -3
View File
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
] as MenuItem[])
: ([
{ kind: "separator" },
openInMenu(slot.model.path),
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
{ kind: "separator" },
localHistoryMenu(slot.model.path),
gitMenu(slot.model.path),
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
}
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
function openInMenu(path: string): MenuItem {
function openInMenu(path: string, line?: number): MenuItem {
return {
label: "Open In",
submenu: [
@@ -1971,7 +1971,7 @@ function openInMenu(path: string): MenuItem {
: []),
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
{ kind: "separator" },
...menuGroupItems("openIn", { paths: [path] }),
...menuGroupItems("openIn", { paths: [path], line }),
],
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
import { writeClipboardText } from "@/platform/clipboard";
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { EditorSelection } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { structure } from "@/editor/structure";
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
if (view.state.readOnly) return;
let text: string;
try {
text = (await navigator.clipboard?.readText()) ?? "";
text = await readClipboardPlainText();
} catch {
hint("Cannot read the clipboard");
return;
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more