Compare commits

..
Author SHA1 Message Date
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
119 changed files with 300 additions and 5405 deletions

No files matched your search

-9
View File
@@ -151,13 +151,6 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -240,8 +233,6 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
];
const platforms = {};
for (const sig of sigs) {
+9 -139
View File
@@ -1680,15 +1680,6 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1768,20 +1759,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1792,56 +1769,15 @@ pub fn goto_declaration_in(
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -3151,9 +3087,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version and no local repository has its POM.
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry declares the version; its artifact's POM is not at hand either.
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3165,72 +3101,6 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
-15
View File
@@ -27,13 +27,6 @@ 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;
@@ -380,14 +373,6 @@ 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,18 +8,13 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::Arc;
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
/// started or failed to.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+1 -52
View File
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text
}
/// 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> {
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
@@ -102,56 +101,6 @@ pub 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";
+1 -11
View File
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
@@ -296,16 +296,6 @@ pub struct McpServerSettings {
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool,
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
}
impl Default for McpServerSettings {
fn default() -> Self {
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
}
}
/// Absent profiles retain automatic discovery for existing projects.
-4
View File
@@ -60,8 +60,6 @@ pub struct Host {
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
pub mcp_agent_runs: mcp::AgentRuns,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
@@ -143,7 +141,6 @@ impl Host {
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
@@ -272,7 +269,6 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.mcp_agent_runs.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
-176
View File
@@ -1,176 +0,0 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+9 -34
View File
@@ -30,14 +30,9 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
@@ -55,7 +50,7 @@ The maximum accepted limit is 5000 lines.",
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
if available <= max_lines {
return Ok(range(start, count));
}
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
@@ -261,7 +245,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max, false).unwrap()
render_slice(text, start, max).unwrap()
}
#[test]
@@ -270,27 +254,18 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_a_long_default_read() {
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+1 -4
View File
@@ -1,9 +1,8 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
pub(crate) mod run;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
@@ -17,7 +16,6 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
-418
View File
@@ -1,418 +0,0 @@
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::sync::Arc;
const RUN_TIMEOUT_MS: usize = 10_000;
const WAIT_TIMEOUT_MS: usize = 30_000;
const STOP_TIMEOUT_MS: usize = 10_000;
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
const DEFAULT_MAX_LINES: usize = 2000;
/// Bytes of the log read for one result at most.
const READ_CAP: usize = 1 << 20;
/// Lines of output quoted when a name is still running.
const BUSY_TAIL_LINES: usize = 20;
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
wait for their confirmation before you continue.";
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
(keeps the start), MIDDLE (keeps both ends) or NONE";
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
call.host()
.mcp_agent_runs
.get(call.root(), &name)
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
}
/// `status` of `run`, with interventions not told yet.
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
let mut out = run.status();
out.extend(extra);
let interventions = run.take_interventions();
if !interventions.is_empty() {
out.insert("interventions".into(), interventions.into());
out.insert("advice".into(), INTERVENTION_ADVICE.into());
}
Output::json(Value::Object(out))
}
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
/// as `truncateMode` says.
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
let max_lines = match call.opt_i64("maxLinesCount")? {
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
Some(n) => n as usize,
None => DEFAULT_MAX_LINES,
};
let mode = match call.opt_str("truncateMode")? {
Some(name) => TruncateMode::parse(&name)
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
None => TruncateMode::Start,
};
let len = run.log_len();
let from = from.min(len);
// A long output is read from the end that stays.
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
let (raw, start, end) = run.read(start, READ_CAP)?;
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
let mut out = Map::new();
out.insert("output".into(), text.into());
if truncated > 0 {
out.insert("truncatedLines".into(), truncated.into());
}
out.insert("outputStart".into(), start.into());
out.insert("nextOffset".into(), end.into());
Ok(out)
}
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
Ok(tokio::time::Instant::now() + timeout(call, default)?)
}
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
call.opt_str("pattern")?
.filter(|p| !p.is_empty())
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
.transpose()
}
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
match call.opt_i64(name)? {
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
n => Ok(n.map(|n| n as u64)),
}
}
fn busy(run: &AgentRun) -> IdeError {
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
IdeError::invalid(format!(
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
run.name,
run.command(),
clean_output(&tail).trim_end()
))
}
pub fn run_command() -> Tool {
Tool::new(
"run_command",
&format!(
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
),
|call| async move {
let name = tab_name(&call.str("name")?)?;
let command = call.str("command")?;
if command.trim().is_empty() {
return Err(IdeError::invalid("command must not be empty"));
}
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
Some(dir) => {
let path = call.resolve_inside(&dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
}
path
}
None => call.root().to_path_buf(),
};
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
let host = call.host();
let runs = &host.mcp_agent_runs;
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
return Err(busy(&run));
}
let shown_cwd = call.display(&cwd);
let mut detail = format!(
"{command}\n\nTab: {name}\nWorking directory: {}",
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
);
for (key, value) in env.iter().flatten() {
detail.push_str(&format!("\n{key}={value}"));
}
call.confirm(&name, Some(detail)).await?;
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
let request = ide_core::event::McpUiRequest::AgentRun {
name: name.clone(),
command: command.clone(),
cwd: cwd.into(),
env: env.unwrap_or_default().into_iter().collect(),
};
if let Err(e) = call.ask(request).await {
let error = run.start_error();
runs.abandon(&run);
return Err(match error {
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
None => e,
});
}
if let Some(error) = run.start_error() {
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
}
let from = run.process_offset();
let end = run.wait(deadline, None, from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::required(
"command",
ParamType::String,
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Directory inside the project to run in, relative to the project root; default: the project root",
))
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
.param(Param::optional(
"timeout",
ParamType::Integer,
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
keeps running after it",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn wait_for_run() -> Tool {
Tool::new(
"wait_for_run",
&format!(
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
let pattern = pattern(&call)?;
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
.param(Param::optional(
"pattern",
ParamType::String,
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
))
.param(Param::optional(
"offset",
ParamType::Integer,
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
command's output starts",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn read_run_output() -> Tool {
Tool::new(
"read_run_output",
&format!(
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let from = offset(&call, "offset")?.unwrap_or(0);
let extra = output_view(&call, &run, from)?;
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn get_run_status() -> Tool {
Tool::new(
"get_run_status",
&format!(
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
result(&run, Map::new())
},
)
.param(Param::required("name", ParamType::String, NAME))
}
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let runs: Vec<Value> =
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
Output::json(serde_json::json!({ "runs": runs }))
},
)
}
/// What a key does to a command on pipes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Key {
/// Writes this text to its input.
Text(&'static str),
/// Ctrl+C: stops it, as `stop_run` does.
Interrupt,
/// Ctrl+D: ends its input.
EndOfInput,
}
fn key(name: &str) -> IdeResult<Key> {
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
"enter" | "return" => Key::Text("\n"),
"tab" => Key::Text("\t"),
"ctrl+c" => Key::Interrupt,
"ctrl+d" => Key::EndOfInput,
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
return Err(IdeError::invalid(format!(
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
Ctrl+C to stop it or Ctrl+D to end its input"
)));
}
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
})
}
pub fn send_run_input() -> Tool {
Tool::new(
"send_run_input",
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|call| async move {
let run = find(&call)?;
let text = call.opt_str("text")?.unwrap_or_default();
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
if text.is_empty() && keys.is_empty() {
return Err(IdeError::invalid("pass `text`, `keys` or both"));
}
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
call.project().require_trust()?;
let host = call.host();
let mut data = text;
for key in keys {
match key {
Key::Text(text) => data.push_str(text),
Key::EndOfInput | Key::Interrupt => {
if !data.is_empty() {
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
}
if key == Key::EndOfInput {
crate::ops::run::close_stdin(&host, id)?;
} else {
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
break;
}
}
}
}
if !data.is_empty() {
host.processes.write_stdin(id, &data)?;
}
result(&run, Map::new())
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("text", ParamType::String, "Text to type"))
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
}
pub fn stop_run() -> Tool {
Tool::new(
"stop_run",
&format!(
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
keeps `stopping`: call again with `force`. The tab stays open with its output."
),
|call| async move {
let run = find(&call)?;
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
let force = call.flag("force", false)?;
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
let host = call.host();
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
if force {
// A second Stop kills the tree.
crate::ops::run::stop(&host, &run.window(), id)?;
}
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
let mut extra = Map::new();
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
}
pub fn tools() -> Vec<Tool> {
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
assert!(key("F13").unwrap_err().message.contains("unknown key"));
}
}
+3 -2
View File
@@ -15,6 +15,7 @@ use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (0 times out at once) the build is stopped.",
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
+3 -1
View File
@@ -19,6 +19,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
+5 -14
View File
@@ -1,7 +1,5 @@
//! Run configurations, builds and agent commands; every start asks the user first.
//! Run configurations and builds; every call asks the user first.
pub(crate) mod agent;
mod agent_tools;
mod build;
mod configs;
mod output;
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
let mut tools =
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
+2 -42
View File
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned()
}
/// 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).
/// Configs whose working directory 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 = scope_of(root, c);
let cwd = cwd_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();
@@ -935,33 +922,6 @@ 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");
+2 -131
View File
@@ -38,14 +38,6 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -57,8 +49,6 @@ fn search_text() -> Tool {
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -67,7 +57,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
@@ -88,7 +75,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
@@ -114,7 +99,6 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -161,13 +145,6 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
impl SearchItem {
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
"name": string, "container": string,
},
"required": ["filePath"],
} },
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q)
}
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -522,7 +416,6 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
@@ -601,7 +494,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let mut items: Vec<SearchItem> = hits
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
@@ -836,7 +726,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -987,17 +869,6 @@ mod tests {
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
+3 -17
View File
@@ -313,11 +313,10 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
@@ -456,8 +455,7 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -468,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
+1 -20
View File
@@ -234,19 +234,7 @@ pub async fn start_request(
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let mut config = config;
let agent = match host.project(window) {
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
Err(_) => None,
};
let on_output = match &agent {
Some(agent) => agent.wrap_output(on_output),
None => on_output,
};
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
if let Some(agent) = &agent {
agent.started(&result);
}
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
@@ -261,7 +249,6 @@ async fn start_inner(
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
@@ -295,9 +282,6 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
host.processes.stop_with_grace(id, grace)
}
/// Input the user typed into a run (the agent's goes to the process directly).
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data);
}
-173
View File
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -463,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
}
#[tokio::test(flavor = "multi_thread")]
async fn check_files_says_why_a_file_could_not_be_checked() {
let h = Harness::new();
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
h.trust().await;
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
assert_eq!(files["files"][0]["problems"], json!([]));
assert_eq!(files["files"][1]["status"], "failed", "{files}");
assert!(files["files"][1]["error"].is_string());
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
let none = h.error("check_files", json!({"filePaths": []})).await;
assert!(none.contains("at least one file"), "{none}");
}
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
/// agent tab's configuration (shell from the default) and says whether it started. Returns
/// the configurations it started, for reruns.
#[cfg(unix)]
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
tokio::spawn(async move {
let mut handled = std::collections::HashSet::new();
loop {
let request = events.lock().unwrap().iter().find_map(|e| match e {
IdeEvent::McpRequest {
id,
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
_ => None,
});
let Some((id, name, command, cwd, env)) = request else {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
continue;
};
handled.insert(id);
let mut config = ide_run::RunConfig {
id: format!("mcp-agent:{name}"),
name: name.clone(),
kind: "shell".into(),
command,
args: Vec::new(),
cwd: Some(cwd),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: true,
extras: Default::default(),
};
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
config.extras.target = Some(String::new());
out.lock().unwrap().push(config.clone());
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
let error = result.err().map(|e| e.message);
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
}
});
started
}
#[cfg(unix)]
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
let id = format!("mcp-agent:{name}");
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
let h = Harness::new();
h.trust().await;
h.host.mcp_requests.set_ready("main", true);
h.host
.global_settings
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
.unwrap();
let started = run_tool_window(&h);
// A command that finishes within the wait, in a tab named with the prefix.
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
assert_eq!(done["name"], "_agent_check", "{done}");
assert_eq!(done["state"], "failed", "{done}");
assert_eq!(done["exitCode"], 3);
assert_eq!(done["startedBy"], "agent");
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
assert!(done.get("interventions").is_none());
// The finished name runs the next command; a running one is refused, never stopped.
let running =
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
assert_eq!(running["state"], "running", "{running}");
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
assert_eq!(matched["waitEnded"], "matched", "{matched}");
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
let process = agent_process(&h, "_agent_check");
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
assert!(stopped.get("interventions").is_none());
// The user's Stop, rerun and closing the tab are reported with their time.
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
let config = started.lock().unwrap().last().cloned().unwrap();
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
let status = h.json("get_run_status", json!({"name": "server"})).await;
assert_eq!(status["startedBy"], "user", "{status}");
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
let closed = h.json("get_run_status", json!({"name": "server"})).await;
assert!(closed["tabClosedAt"].is_string(), "{closed}");
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
let lines =
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
assert_eq!(lines["truncatedLines"], 1);
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
assert!(key.contains("runs on pipes"), "{key}");
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
}
-116
View File
@@ -22,9 +22,6 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
/// syntax first, semantics later).
const CHECK_SETTLE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug)]
pub struct InspectOptions {
@@ -51,29 +48,6 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId);
}
/// One language server's part in a [`DocumentCheck`].
#[derive(Clone, Debug)]
pub struct CheckedServer {
pub name: String,
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
pub status: ide_core::event::ServerStatus,
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
pub message: Option<String>,
/// Its diagnostics are in the result.
pub checked: bool,
/// It was still analysing (or had not published for this change) when the wait ended.
pub incomplete: bool,
/// Asking it failed.
pub error: Option<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// What became of one file.
#[derive(Clone, Debug)]
pub enum FileInspection {
@@ -224,96 +198,6 @@ impl LspManager {
}
}
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
/// servers the document was saved, since checks that run on save (rust-analyzer's
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
/// to be ready, to answer for this text and to finish the background work it started, up
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
/// problems" from "nothing could check".
pub async fn check_document(
&self,
doc: DocId,
changed: Option<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
// Once the servers have the document: a save sent while one starts would not reach it.
self.did_save(doc, snap.text.as_str());
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
let mut report = CheckedServer {
name: server.name.clone(),
status: server.status(),
message: server.status_message(),
checked: false,
incomplete: false,
error: None,
};
let Some(run) = run else { return Some((report, Vec::new())) };
let t = Target { server: server.clone(), run };
if !t.caps().restriction.diagnostics() {
return None;
}
let pull = t.caps().has("diagnosticProvider");
let mut since = changed;
let raw = loop {
// A pull asks for this text; a push server is waited on to publish for it.
let raw = if pull {
server.wait_idle(left(), cancel).await;
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
} else {
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
wait_for_publish(server, &snap.path, since, opts, cancel).await
};
// The save may have started work (a `cargo check`) that publishes when it ends.
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
break raw;
}
if !server.wait_idle(left(), cancel).await {
break raw.map(|_| server.diagnostics_for(&snap.path));
}
// Idle: its last publish is final once quiet.
since = None;
};
report.status = server.status();
match raw {
Ok(items) => {
report.checked = true;
let fresh =
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
report.incomplete = !fresh || server.is_busy();
let conv = self.conv(t.enc());
let items = items
.into_iter()
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
.collect::<Vec<_>>();
Some((report, items))
}
Err(e) => {
report.error = Some(e.message);
Some((report, Vec::new()))
}
}
});
let results = tokio::select! {
r = futures::future::join_all(per_server) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let mut out = DocumentCheck::default();
for (report, items) in results.into_iter().flatten() {
out.servers.push(report);
out.diagnostics.extend(items);
}
Ok(out)
}
async fn pull_diagnostics(
&self,
t: &Target,
+1 -1
View File
@@ -40,7 +40,7 @@ mod vue_navigation;
mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
-18
View File
@@ -658,24 +658,6 @@ impl Server {
}
}
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
/// cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.phase_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`;
-51
View File
@@ -2178,57 +2178,6 @@ async fn missing_server_reports_once() {
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
let (server, uri) = (s.clone(), uri_of(&path));
let publisher = tokio::spawn(async move {
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
"severity": 1, "message": "on save" });
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
});
let cancel = CancellationToken::new();
let changed = Some(std::time::Instant::now());
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
publisher.await.unwrap();
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
// A change the server never answered: what it has is reported as not fresh.
let changed = Some(std::time::Instant::now());
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_reports_servers_that_cannot_check() {
let cancel = CancellationToken::new();
let h = harness(&[("ghost", json!({}))]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(check.diagnostics.is_empty());
let s = &check.servers[0];
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
assert_eq!(s.message.as_deref(), Some("install ghost"));
let h = harness(&[("mock", incremental_caps())]);
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Misconfigured).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
assert!(!check.servers[0].checked);
}
#[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]);
+22 -249
View File
@@ -3,39 +3,17 @@
use crate::discovery::Discovery;
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
use bytes::Bytes;
use http_body_util::{BodyExt, Either, Full, Limited};
use hyper::body::{Frame, Incoming};
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::Incoming;
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::sync::watch;
use tokio::task::{JoinError, JoinHandle};
use tokio_util::sync::DropGuard;
/// Largest request body.
const MAX_BODY: usize = 8 << 20;
/// A call still running after this long is answered on an event stream (when the client
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
/// response has not started (Claude Code after a minute).
#[cfg(not(test))]
const STREAM_AFTER: Duration = Duration::from_secs(1);
#[cfg(test)]
const STREAM_AFTER: Duration = Duration::from_millis(100);
/// How often a streamed call reports that it is still running: progress notifications reset
/// the client's idle timeout (Claude Code's is five minutes).
#[cfg(not(test))]
const HEARTBEAT: Duration = Duration::from_secs(15);
#[cfg(test)]
const HEARTBEAT: Duration = Duration::from_millis(100);
type Handled = Result<Option<Value>, Value>;
type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
@@ -162,11 +140,15 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
}
}
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
async fn serve(
req: Request<Incoming>,
shared: Arc<Shared>,
scope: String,
) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(respond(req, &shared, scope).await)
}
type Reply = Response<Body>;
type Reply = Response<Full<Bytes>>;
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
if req.uri().path() != "/mcp" {
@@ -181,7 +163,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
return response;
}
if req.method() != Method::POST {
// No stream of server-initiated messages: every answer is in the POST's response.
// No server-sent event streams: every answer is in the POST's response.
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
return response;
@@ -193,41 +175,25 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
}
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
let project = project_of(&req);
let streams =
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
Ok(body) => body.to_bytes(),
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
};
let message = serde_json::from_slice::<Value>(&body).ok();
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
let response_scope = scope.clone();
let registry = shared.registry.clone();
let shared = shared.clone();
let cancel = shared.cancel.child_token();
// Cancels the call when the client goes away (the response is dropped).
let guard = cancel.clone().drop_guard();
let mut handled = tokio::task::spawn_blocking(move || {
let _guard = cancel.clone().drop_guard();
let handled = tokio::task::spawn_blocking(move || {
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
});
if streams && !initializing {
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
Ok(done) => return answer(done, false, &response_scope),
Err(_) => return event_stream(handled, id, progress_token, guard),
}
}
let done = handled.await;
drop(guard);
answer(done, initializing, &response_scope)
}
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
})
.await;
match handled {
Ok(Ok(Some(reply))) => {
let mut response = json(StatusCode::OK, &reply);
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
response.headers_mut().insert("mcp-session-id", value);
}
response
@@ -238,71 +204,6 @@ fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str)
}
}
/// Answers a call that is still running on an event stream: a progress notification every
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
/// answer. The call is cancelled as soon as the client goes away.
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
tokio::spawn(async move {
let _guard = guard;
let started = tokio::time::Instant::now();
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
loop {
tokio::select! {
done = &mut handled => {
let reply = match done {
Ok(Ok(Some(reply)) | Err(reply)) => reply,
Ok(Ok(None)) => return,
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
};
let _ = tx.send(event(&reply)).await;
return;
}
_ = beat.tick() => {
let elapsed = started.elapsed();
let still_running = match &token {
Some(token) => event(&json!({
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": token,
"progress": elapsed.as_secs_f64(),
"message": format!("running for {}s", elapsed.as_secs()),
},
})),
None => Bytes::from_static(b": running\n\n"),
};
if tx.send(still_running).await.is_err() {
return;
}
}
_ = tx.closed() => return,
}
}
});
let mut response = Response::new(Either::Right(EventStream(rx)));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
/// One server-sent event carrying a JSON-RPC message.
fn event(message: &Value) -> Bytes {
Bytes::from(format!("event: message\ndata: {message}\n\n"))
}
/// A `text/event-stream` body, written by [`event_stream`]'s task.
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
impl hyper::body::Body for EventStream {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
}
}
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
@@ -341,22 +242,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
})
}
fn plain(status: StatusCode, text: &str) -> Reply {
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
response
}
fn json(status: StatusCode, value: &Value) -> Reply {
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
*response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
response
}
fn empty(status: StatusCode) -> Reply {
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::new()));
*response.status_mut() = status;
response
}
@@ -364,134 +265,6 @@ fn empty(status: StatusCode) -> Reply {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
struct Slow(Arc<AtomicBool>);
impl ToolRegistry for Slow {
fn projects(&self) -> Vec<PathBuf> {
vec![PathBuf::from("/project")]
}
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
Ok(Vec::new())
}
fn call(
&self,
_: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, crate::ToolCallError> {
if name == "wait" {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(10));
}
self.0.store(true, Ordering::SeqCst);
return Err(crate::ToolCallError::Failed("cancelled".into()));
}
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
std::thread::sleep(Duration::from_millis(ms));
Ok("done".into())
}
}
fn start() -> (McpServer, Arc<AtomicBool>) {
let cancelled = Arc::new(AtomicBool::new(false));
let registry = Arc::new(Slow(cancelled.clone()));
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
}
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
if let Some(token) = token {
params["_meta"] = json!({ "progressToken": token });
}
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
}
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
let body = body.to_string();
let port = server.port();
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
server.token(),
body.len()
);
stream.write_all(request.as_bytes()).await.unwrap();
stream
}
/// The whole response, lowercased headers and the body as sent (chunked or not).
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
let mut out = String::new();
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
let (head, body) = out.split_once("\r\n\r\n").unwrap();
(head.to_ascii_lowercase(), body.to_owned())
}
fn events(body: &str) -> Vec<Value> {
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
}
const BOTH: &str = "application/json, text/event-stream";
#[tokio::test(flavor = "multi_thread")]
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
let (server, _) = start();
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
assert!(head.contains("content-type: application/json"), "{head}");
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
assert!(head.contains("content-type: text/event-stream"), "{head}");
let messages = events(&body);
let (answer, progress) = messages.split_last().unwrap();
assert_eq!(answer["id"], 3);
assert_eq!(answer["result"]["content"][0]["text"], "done");
assert!(progress.len() >= 2, "{body}");
for pair in progress.windows(2) {
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
}
for p in progress {
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
}
// Without a progress token, comments keep the stream going.
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
assert!(body.contains(": running"), "{body}");
assert_eq!(events(&body).len(), 1, "{body}");
// A client that takes only JSON waits for it.
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
assert!(head.contains("content-type: application/json"), "{head}");
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
let (server, cancelled) = start();
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
let mut head = Vec::new();
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
assert!(n > 0, "the stream ended early");
head.extend_from_slice(&buf[..n]);
}
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
drop(stream);
tokio::time::timeout(Duration::from_secs(10), async {
while !cancelled.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the call was cancelled");
}
#[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() {
+14 -106
View File
@@ -35,8 +35,7 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
}
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
/// writes what it answers, if anything, one message per line to `output`: the
/// answer, and before it the progress notifications of a long call.
/// writes its answer, if any, as one line to `output`.
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
where
R: AsyncBufRead + Unpin,
@@ -45,37 +44,23 @@ where
let scope = crate::server::new_token()?;
let mut lines = input.lines();
let mut pending = tokio::task::JoinSet::new();
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
let mut eof = false;
loop {
if eof && pending.is_empty() && received.is_empty() {
break;
}
let reply = tokio::select! {
line = lines.next_line(), if !eof => {
let Some(line) = line? else { eof = true; continue };
if line.trim().is_empty() { continue; }
match serde_json::from_str::<Value>(&line) {
Ok(message) => {
let (discovery, project, scope, sent) =
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
pending.spawn(async move {
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
let _ = sent.send(reply);
}
});
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
continue;
}
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
}
}
Some(message) = received.recv() => Some(message),
done = pending.join_next(), if !pending.is_empty() => {
if let Some(Err(e)) = done {
return Err(std::io::Error::other(e));
}
continue;
}
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
else => break,
};
if let Some(reply) = reply {
output.write_all(reply.to_string().as_bytes()).await?;
@@ -87,24 +72,15 @@ where
}
/// The IDE's answer to `message`, or an error response for each request in
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
/// with the messages before it.
async fn relay(
discovery: &Path,
project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Option<Value> {
let failure = match post(discovery, project, message, scope, sent).await {
Ok(Answer::Streamed { answered: true }) => return None,
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
/// it when the IDE can't be reached.
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
let failure = match post(discovery, project, message, scope).await {
Ok((StatusCode::ACCEPTED, _)) => return None,
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
Ok(reply) => return Some(reply),
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
},
Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
Ok((status, _)) => format!("OpenIDEA answered {status}"),
Err(e) => e,
};
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
@@ -124,23 +100,9 @@ fn requests(message: &Value) -> Vec<Value> {
}
}
enum Answer {
Body(StatusCode, Bytes),
/// The messages of an event stream went to the channel; `answered`: one was a response.
Streamed {
answered: bool,
},
}
/// POSTs `message` to the server in the discovery file; a message for the
/// user when that fails.
async fn post(
discovery: &Path,
project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Result<Answer, String> {
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
@@ -163,60 +125,6 @@ async fn post(
.map_err(|e| e.to_string())?;
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
let status = response.status();
let streamed = response
.headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
let mut body = response.into_body();
if !streamed {
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
return Ok(Answer::Body(status, body));
}
let (mut buffer, mut answered) = (Vec::new(), false);
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
let Ok(data) = frame.into_data() else { continue };
buffer.extend_from_slice(&data);
for data in take_events(&mut buffer) {
if let Ok(message) = serde_json::from_str::<Value>(&data) {
answered |= message.get("method").is_none();
let _ = sent.send(message);
}
}
}
Ok(Answer::Streamed { answered })
}
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
/// the rest. Comments and other fields are skipped.
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
let mut events = Vec::new();
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
let block: Vec<u8> = buffer.drain(..end + 2).collect();
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
let data: Vec<&str> = block
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|d| d.strip_prefix(' ').unwrap_or(d))
.collect();
if !data.is_empty() {
events.push(data.join("\n"));
}
}
events
}
#[cfg(test)]
mod tests {
use super::take_events;
#[test]
fn events_are_taken_whole_and_comments_skipped() {
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
buffer.extend_from_slice(b":2}\n\n");
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
assert!(buffer.is_empty());
}
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
Ok((status, body))
}
-18
View File
@@ -39,10 +39,6 @@ impl ToolRegistry for Registry {
match name {
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
"sleep" => {
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
Ok("slept".into())
}
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
}
}
@@ -256,20 +252,6 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
}
#[tokio::test(flavor = "multi_thread")]
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
let s = setup();
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
let mut output = Vec::new();
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
let replies: Vec<Value> =
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let answer = replies.last().unwrap();
assert_eq!(answer["id"], 4, "{replies:?}");
assert_eq!(answer["result"]["content"][0]["text"], "slept");
}
#[tokio::test(flavor = "multi_thread")]
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
struct Many(Vec<PathBuf>);
+13 -50
View File
@@ -498,8 +498,7 @@ impl ProcessManager {
None,
)?;
}
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
Ok(())
})();
if let Err(e) = result {
@@ -630,10 +629,7 @@ impl ProcessManager {
#[cfg(unix)]
Some(Box::new(wait_readable)),
)
.and_then(|_| {
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
});
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
@@ -1124,35 +1120,18 @@ 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,
decoders: [OutputDecoder; 2],
encoding: Option<String>,
notice: Option<String>,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
let [mut out_dec, mut err_dec] = decoders;
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
// "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);
@@ -1161,8 +1140,6 @@ 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;
@@ -1177,7 +1154,6 @@ 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()
@@ -1194,8 +1170,14 @@ fn spawn_pump(
Stream::Stdout => (&mut out_dec, &mut out_notice),
Stream::Stderr => (&mut err_dec, &mut err_notice),
};
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 };
match notice {
Some(filter) => {
dec.decode_into(&bytes, &mut decoded);
filter.filter(&decoded, &mut pending);
decoded.clear();
}
None => dec.decode_into(&bytes, &mut pending),
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
@@ -1211,25 +1193,6 @@ 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);
}
+1 -390
View File
@@ -136,7 +136,6 @@ mod tests {
pub enum OutputDecoder {
Utf8(Utf8Decoder),
Other(Box<encoding_rs::Decoder>),
Mixed(Box<MixedDecoder>),
}
impl OutputDecoder {
@@ -148,36 +147,10 @@ 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);
}
}
@@ -189,218 +162,10 @@ 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 {
@@ -415,8 +180,7 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
#[cfg(test)]
mod encoding_tests {
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
use ide_core::output_encoding::encoding_for_code_page;
use super::OutputDecoder;
#[test]
fn gbk_split_across_chunks() {
@@ -438,157 +202,4 @@ 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
@@ -1,69 +0,0 @@
#![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");
}
+15 -69
View File
@@ -17,18 +17,11 @@ pub(crate) const EXTENSION: &str = "vue";
const CLI_SERVICE: &str = "@vue/cli-service";
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]` (an object, an
/// ES module's default, a function of the environment, or a list of configurations), after
/// whatever the configuration itself prints.
const PRINT_ALIASES: &str = "let c = require(process.argv[1]); if (c && c.__esModule) c = c.default; \
if (typeof c === 'function') c = c({}, { mode: 'development' }); if (Array.isArray(c)) c = c[0]; \
const a = (c && c.resolve && c.resolve.alias) || {}; \
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
/// the configuration itself prints.
const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
/// Webpack configurations of Vue projects without Vue CLI: the project's own, and the
/// `vue init webpack` (Vue CLI 2) template's.
const WEBPACK_CONFIGS: &[&str] =
&["webpack.config.js", "webpack.config.cjs", "build/webpack.base.conf.js", "build/webpack.dev.conf.js"];
type Paths = BTreeMap<String, Vec<String>>;
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
@@ -49,33 +42,24 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
})
}
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` →
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the webpack
/// configuration (the project is trusted: language servers only run then): the one
/// `@vue/cli-service` resolves with the project's `vue.config.js`, else the project's own
/// (`webpack.config.js`, Vue CLI 2's `build/webpack.base.conf.js`). Without one (Vite, or
/// no Node), the `@` → `src` alias every Vue template sets up. Empty for other projects.
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
/// is trusted: language servers only run then); else the alias Vue CLI always adds.
/// Empty for other projects.
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
let dependencies = package_dependencies(&manifest);
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
if !cli && !dependencies.iter().any(|d| d == "vue") {
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
return Paths::new();
}
let config = if cli {
root.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file())
} else {
WEBPACK_CONFIGS.iter().map(|f| root.join(f)).find(|f| f.is_file())
};
let mut inputs: Vec<PathBuf> = ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"]
.iter()
.chain(WEBPACK_CONFIGS)
.map(|f| root.join(f))
.collect();
let config = root
.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file());
let mut inputs: Vec<PathBuf> =
["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
inputs.extend(config.iter().cloned());
inputs.extend(node.map(Path::to_path_buf));
let now = stamp(&inputs);
@@ -87,7 +71,6 @@ pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let aliases = config
.zip(node)
.and_then(|(config, node)| webpack_aliases(root, &config, node))
.filter(|aliases| !aliases.is_empty())
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
let paths = alias_paths(&aliases);
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
@@ -198,43 +181,6 @@ mod tests {
);
}
#[test]
fn other_vue_projects_get_their_webpack_aliases_else_the_at_alias() {
let tmp = tempfile::tempdir().unwrap();
// As `__dirname` sees it (macOS: /private/var).
let t = &dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(t.join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#).unwrap();
std::fs::create_dir_all(t.join("src/views")).unwrap();
assert!(cli_paths(t, None).is_empty(), "not a Vue project");
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
let (src, views) = (slash(t.join("src")), slash(t.join("src/views")));
let at = Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]);
assert_eq!(cli_paths(t, None), at, "Vite or no Node: the template alias");
let Some(node) = crate::exec::which("node") else { return };
// `vue init webpack`: a function of the environment is called, its aliases taken.
std::fs::create_dir_all(t.join("build")).unwrap();
std::fs::write(
t.join("build/webpack.base.conf.js"),
r#"const path = require('path');
const resolve = (dir) => path.join(__dirname, '..', dir);
module.exports = (env) => ({ resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': resolve('src'), views: resolve('src/views') } } });"#,
)
.unwrap();
assert_eq!(
cli_paths(t, Some(&node)),
Paths::from([
("@".into(), vec![src.clone()]),
("@/*".into(), vec![format!("{src}/*")]),
("views".into(), vec![views.clone()]),
("views/*".into(), vec![format!("{views}/*")]),
])
);
// A configuration that can't be loaded (dependencies not installed): the template alias.
std::fs::write(t.join("build/webpack.base.conf.js"), "require('not-installed-plugin');").unwrap();
assert_eq!(cli_paths(t, Some(&node)), at);
}
#[test]
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
let Some(node) = crate::exec::which("node") else { return };
+2
View File
@@ -19,6 +19,8 @@ tar = "0.4"
zip.workspace = true
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dev-dependencies]
-2
View File
@@ -20,8 +20,6 @@ mod bundled;
pub mod formatter;
pub mod jdk;
pub mod platform;
#[cfg(any(windows, test))]
pub mod portable_update;
mod process;
pub mod registry;
pub mod server;
-223
View File
@@ -1,223 +0,0 @@
//! In-place updates of the Windows unpack-and-run distribution.
use ide_core::{IdeError, IdeResult};
use std::fs;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
/// `bytes` must already have passed the updater's signature verification.
pub fn install(exe: &Path, bytes: &[u8]) -> IdeResult<()> {
let directory = exe.parent().ok_or_else(|| IdeError::invalid("executable has no parent directory"))?;
let staging = tempfile::Builder::new().prefix(".openidea-update-").tempdir_in(directory)?;
let files = unpack(bytes, staging.path(), exe)?;
let mut replaced = Vec::new();
for (source, target) in files {
match replace_file(&source, &target) {
Ok(backup) => replaced.push((target, backup)),
Err(error) => {
for (target, backup) in replaced.into_iter().rev() {
fs::remove_file(&target)?;
if let Some(backup) = backup {
fs::rename(&backup, &target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
}
return Err(error);
}
}
}
Ok(())
}
/// Old executables stay locked until their process exits; retry cleanup on the next start.
pub fn cleanup(exe: &Path) {
for target in [exe.to_path_buf(), exe.with_file_name("WebView2Loader.dll")] {
let _ = fs::remove_file(backup_path(&target));
}
}
fn unpack(bytes: &[u8], staging: &Path, exe: &Path) -> IdeResult<Vec<(PathBuf, PathBuf)>> {
let invalid = |error| IdeError::invalid(format!("portable update archive: {error}"));
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut files = Vec::new();
let mut has_exe = false;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(invalid)?;
let name = entry.name().map_err(invalid)?;
let target = match name.as_ref() {
"openidea.exe" => {
has_exe = true;
exe.to_path_buf()
}
"WebView2Loader.dll" => exe.with_file_name("WebView2Loader.dll"),
name => return Err(IdeError::invalid(format!("unexpected portable update file: {name}"))),
};
if !entry.is_file() || entry.is_symlink() || files.iter().any(|(_, path)| path == &target) {
return Err(IdeError::invalid("portable update contains a duplicate or non-regular file"));
}
let source = staging.join(name.as_ref());
let mut file = fs::File::create(&source)?;
io::copy(&mut entry, &mut file)?;
file.sync_all()?;
files.push((source, target));
}
if !has_exe {
return Err(IdeError::invalid("portable update is missing openidea.exe"));
}
Ok(files)
}
fn replace_file(source: &Path, target: &Path) -> IdeResult<Option<PathBuf>> {
let backup = if target.try_exists()? {
if !target.is_file() {
return Err(IdeError::invalid(format!("not an application file: {}", target.display())));
}
let backup = backup_path(target);
if backup.try_exists()? {
fs::remove_file(&backup)?;
}
// Windows forbids overwriting a running exe, but allows renaming it.
fs::rename(target, &backup)?;
Some(backup)
} else {
None
};
if let Err(error) = fs::rename(source, target) {
if let Some(backup) = &backup {
fs::rename(backup, target).map_err(|restore| {
IdeError::internal(format!(
"{error}; restore {} from {}: {restore}",
target.display(),
backup.display()
))
})?;
}
return Err(error.into());
}
Ok(backup)
}
fn backup_path(target: &Path) -> PathBuf {
let mut path = target.as_os_str().to_os_string();
path.push(".openidea-old");
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn package(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn replaces_application_files_and_preserves_configuration() {
for name in ["openidea.exe", "我的 IDE.exe"] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join(name);
fs::write(&exe, b"old exe").unwrap();
fs::write(tmp.path().join("WebView2Loader.dll"), b"old dll").unwrap();
fs::write(tmp.path().join("settings.toml"), b"my settings").unwrap();
fs::create_dir(tmp.path().join("data")).unwrap();
fs::write(tmp.path().join("data/plugins.json"), b"my plugins").unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
install(&exe, &bytes).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(tmp.path().join("WebView2Loader.dll")).unwrap(), b"new dll");
assert_eq!(fs::read(tmp.path().join("settings.toml")).unwrap(), b"my settings");
assert_eq!(fs::read(tmp.path().join("data/plugins.json")).unwrap(), b"my plugins");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
assert!(!backup_path(&tmp.path().join("WebView2Loader.dll")).exists());
}
}
#[test]
fn accepts_an_exe_only_package() {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
install(&exe, &package(&[("openidea.exe", b"new")])).unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new");
}
#[test]
fn rejects_incomplete_or_unexpected_payloads_before_changing_files() {
for files in [
vec![("WebView2Loader.dll", b"new".as_slice())],
vec![("openidea.exe", b"new"), ("settings.toml", b"overwrite")],
vec![("openidea.exe", b"new"), ("../outside.txt", b"escape")],
] {
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
fs::write(&exe, b"old").unwrap();
assert!(install(&exe, &package(&files)).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
}
}
#[cfg(windows)]
#[test]
fn updates_a_running_executable_and_restores_on_a_locked_companion() {
use std::os::windows::fs::OpenOptionsExt;
use std::process::{Command, Stdio};
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let ping = Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/PING.EXE");
fs::copy(ping, &exe).unwrap();
let original = fs::read(&exe).unwrap();
let dll = tmp.path().join("WebView2Loader.dll");
fs::write(&dll, b"old dll").unwrap();
let mut command = Command::new(&exe);
command.args(["-n", "30", "127.0.0.1"]).stdout(Stdio::null()).stderr(Stdio::null());
ide_core::process::prepare(&mut command);
let mut child = command.spawn().unwrap();
let result = {
assert!(fs::write(&exe, b"overwrite").is_err(), "fixture must hold the running exe");
let held = fs::OpenOptions::new().read(true).share_mode(1).open(&dll).unwrap();
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
assert!(install(&exe, &bytes).is_err());
assert_eq!(fs::read(&exe).unwrap(), original);
assert_eq!(fs::read(&dll).unwrap(), b"old dll");
drop(held);
install(&exe, &bytes)
};
let _ = child.kill();
let _ = child.wait();
result.unwrap();
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
assert_eq!(fs::read(&dll).unwrap(), b"new dll");
cleanup(&exe);
assert!(!backup_path(&exe).exists());
}
#[cfg(windows)]
#[test]
fn restores_the_old_file_when_the_new_file_cannot_be_moved() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().unwrap();
let exe = tmp.path().join("openidea.exe");
let source = tmp.path().join("new.exe");
fs::write(&exe, b"old").unwrap();
fs::write(&source, b"new").unwrap();
let _held = fs::OpenOptions::new().read(true).share_mode(1).open(&source).unwrap();
assert!(replace_file(&source, &exe).is_err());
assert_eq!(fs::read(&exe).unwrap(), b"old");
assert_eq!(fs::read(&source).unwrap(), b"new");
assert!(!backup_path(&exe).exists());
}
}
+1 -16
View File
@@ -108,9 +108,7 @@ fn discover_with_limit(
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if entry.file_type().is_ok_and(|kind| kind.is_dir())
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
{
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
children.push(entry.path());
}
}
@@ -172,19 +170,6 @@ mod tests {
assert_eq!(result.direct[0], root);
}
#[test]
fn skips_virtual_environments_whatever_their_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
project(root, "code10", "pyvenv.cfg");
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
project(root, "conda/pkgs/dep", "setup.py");
let app = project(root, "app", "pyproject.toml");
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
}
#[test]
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
let tmp = tempfile::tempdir().unwrap();
+1 -53
View File
@@ -17,19 +17,6 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
const SKIP_DIRS: &[&str] =
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
/// whatever it is named: its packages are dependencies, not modules of the project.
/// Returns the file that marks it.
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
if dir.join("pyvenv.cfg").is_file() {
Some("pyvenv.cfg")
} else if dir.join("conda-meta").is_dir() {
Some("conda-meta")
} else {
None
}
}
/// Build files whose changes can change the detected roots.
pub(crate) const BUILD_FILES: &[&str] = &[
"pom.xml",
@@ -78,10 +65,7 @@ pub fn affects_roots(rel: &Path) -> bool {
let Some(name) = names.next() else { return false };
BUILD_FILES.contains(&name)
|| ROOT_NAMES.contains(&name)
|| matches!(
name,
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
)
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|| names.next() == Some("src")
}
@@ -147,13 +131,6 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
continue;
}
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
if !out.iter().any(|r| r.path == child) {
let from = join(&child, marker);
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
}
continue;
}
queue.push_back((child, depth + 1));
}
}
@@ -528,35 +505,6 @@ mod tests {
);
}
#[test]
fn virtual_environments_are_excluded_whatever_their_name() {
// A package in the environment's site-packages has build files of its own; as a
// source root it would be put on PYTHONPATH and shadow the standard library.
let dir = tree(&[
"requirements.txt",
"app.py",
"code10/pyvenv.cfg",
"code10/Lib/site-packages/pkg/pyproject.toml",
"code10/Lib/site-packages/pkg/io/__init__.py",
"envs/conda/conda-meta/",
"envs/conda/lib/pkg/setup.py",
"svc/pyproject.toml",
"svc/env/pyvenv.cfg",
"svc/env/lib/python3.12/site-packages/dep/setup.py",
]);
assert_eq!(
summary(&detect(dir.path())),
vec![
"[email protected]",
"code10=Excluded@code10/pyvenv.cfg",
"envs/conda=Excluded@envs/conda/conda-meta",
"svc=Source@svc/pyproject.toml",
"svc/env=Excluded@svc/env/pyvenv.cfg",
]
);
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
}
#[test]
fn first_ecosystem_wins_for_a_shared_folder() {
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
+1 -1
View File
@@ -6,7 +6,7 @@
//! project-relative and `/`-separated; `""` is the project root itself.
mod detect;
pub(crate) use detect::{BUILD_FILES, virtual_env_marker};
pub(crate) use detect::BUILD_FILES;
mod gradle;
mod java;
mod maven;
+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 command permissions don't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and the Command Execution Policy doesn't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
### Review the changes
+2 -16
View File
@@ -85,10 +85,8 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
| Search | `search_text`, `search_regex`, `search_symbol`, `search_file` |
| Editors | `get_all_open_file_paths`, `get_current_file`, `open_file_in_editor` |
| Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` |
| Problems | `get_file_problems`, `get_project_problems`, `check_files` |
| Problems | `get_file_problems`, `get_project_problems` |
| Run and build | `get_run_configurations`, `execute_run_configuration`, `build_project` |
| Commands | `run_command`, `wait_for_run`, `send_run_input`, `stop_run` |
| Command state | `get_run_status`, `read_run_output`, `list_runs` |
| Git | `get_repositories`, `git_status`, `git_diff` |
Notes on what they do:
@@ -96,28 +94,16 @@ Notes on what they do:
- `read_file` also reads sources of dependencies and files inside jar and zip archives.
- `search_symbol` and `find_references` use the IDE's index and the language servers; `get_symbol_info` returns what Quick Documentation shows.
- `get_file_problems` and `get_project_problems` return what the Problems tool window shows, after your inspection profile is applied, rather than a fresh analysis.
- `check_files` is the fresh analysis, for after an edit made by any tool: it syncs each file from disk, tells its language servers it was saved (so on-save checks such as `cargo check` run) and waits until they answered and finished. Each file gets a `status` that says whether the problems are complete, or why it could not be checked: no language server for it, or one that is not installed, misconfigured or crashed.
- `rename_refactoring` renames through the language server, so references that a text search would miss are updated too. It can preview without writing.
- `apply_patch` accepts the Codex `apply_patch` format and unified diffs. Paths must stay inside the project.
- `git_diff` compares files on disk, so unsaved editor text is not included.
## Commands in the Run tool window
`run_command` runs a shell command line in a tab of the Run tool window, with the shell of **Settings › Tools › Terminal**. The agent names the tab and OpenIDEA shows it as `_agent_<name>`, so you see the output as it comes and can type into the command, rerun it or stop it like any run.
As JetBrains IDEs do, the command runs on pipes, not a terminal: programs print plain lines instead of progress bars and redraws. The agent gets that output cleaned of escape sequences and cut to 2000 lines (`maxLinesCount`); beyond that the end stays, or the start or both ends with `truncateMode`. The tab's raw output log stays complete.
- **One tab per name.** A name whose command finished runs the next command in the same tab. A name whose command is still running is refused, with that command and its last output: the agent has to stop it with `stop_run` first or pick another name. Nothing is stopped behind your back.
- **Following a command up.** `wait_for_run` waits for the command to end or for a pattern in its output (a server's "listening" line), `read_run_output` reads the output from any point, `get_run_status` tells whether it is running, succeeded, failed, crashed or was stopped by the agent or by you, and `send_run_input` writes text and Enter to its input; Ctrl+C stops it and Ctrl+D ends its input.
- **You can step in.** What you type into the tab's input line, a rerun, a Stop and closing the tab are reported to the agent with their time. A wait ends at once, and the agent is advised to stop what it is doing, tell you it received the intervention and wait for your confirmation.
- **Finished tabs are greyed out.** Beyond **Finished agent tabs to show** (1 by default) the oldest finished ones move to an archive at the end of the tab strip, shown as **Archived (n)**. Beyond **Archived agent tabs to keep** (999) the oldest archived ones are closed. Running commands are never archived or closed. Both settings are on **Settings › Agents › IDE MCP Server**.
Plugins can add more tools; they appear in the same list. A plugin tool marked as destructive asks for confirmation the same way.
## Safety
- **Untrusted projects** expose only the read-only tools. Tools that write files, rename, reformat, build or run need a project you have trusted.
- **Running and building ask first.** `execute_run_configuration`, `build_project` and `run_command` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
- **Running and building ask first.** `execute_run_configuration` and `build_project` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
- Tools that write refuse paths outside the project.
## Limits
+1 -1
View File
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
### 查看改动
+2 -16
View File
@@ -84,10 +84,8 @@ stdio 配置大致如下(路径以你机器上的为准):
| 搜索 | `search_text`、`search_regex`、`search_symbol`、`search_file` |
| 编辑器 | `get_all_open_file_paths`、`get_current_file`、`open_file_in_editor` |
| 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` |
| 问题 | `get_file_problems`、`get_project_problems`、`check_files` |
| 问题 | `get_file_problems`、`get_project_problems` |
| 运行与构建 | `get_run_configurations`、`execute_run_configuration`、`build_project` |
| 命令 | `run_command`、`wait_for_run`、`send_run_input`、`stop_run` |
| 命令状态 | `get_run_status`、`read_run_output`、`list_runs` |
| Git | `get_repositories`、`git_status`、`git_diff` |
几点说明:
@@ -95,28 +93,16 @@ stdio 配置大致如下(路径以你机器上的为准):
- `read_file` 还能读取依赖的源代码,以及 jar 和 zip 压缩包中的文件。
- `search_symbol` 和 `find_references` 使用 IDE 的索引和语言服务器;`get_symbol_info` 返回的内容与快速文档相同。
- `get_file_problems` 和 `get_project_problems` 返回的是问题工具窗口当前显示的内容(已应用你的检查配置文件),而不是重新分析的结果。
- `check_files` 才是重新分析,适合在任何工具修改文件之后调用:它从磁盘同步每个文件,通知语言服务器文件已保存(这样 `cargo check` 等保存时检查也会运行),并等到服务器答复且分析完成。每个文件都有一个 `status`,说明问题列表是否完整,或者为什么无法检查:没有对应的语言服务器,或者服务器未安装、配置错误、已崩溃。
- `rename_refactoring` 通过语言服务器重命名,文本搜索找不到的引用也会一并更新;也可以只预览不写入。
- `apply_patch` 接受 Codex 的 `apply_patch` 格式和 unified diff,路径必须在项目内。
- `git_diff` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
## 在运行工具窗口中执行命令
`run_command` 在运行工具窗口的一个标签页中,用 **设置 › 工具 › 终端** 中配置的 shell 执行一条命令行。标签名由智能体指定,OpenIDEA 显示为 `_agent_<名称>`。输出实时可见,你也可以像对待其他运行一样向命令输入、重新运行或停止它。
和 JetBrains 的 IDE 一样,命令通过管道运行,而不是在终端里运行:程序输出的是普通文本行,不会有进度条和屏幕重绘。智能体拿到的输出会去掉控制字符,并截断到 2000 行(`maxLinesCount`);超出部分默认保留结尾,也可以用 `truncateMode` 改为保留开头或两头。标签页的原始输出日志保持完整。
- **一个名称对应一个标签。** 名称对应的命令已结束时,下一条命令在同一标签中运行。命令仍在运行时会被拒绝,并返回该命令和它最后的输出:智能体必须先用 `stop_run` 停止它,或者换一个名称。任何进程都不会被悄悄停掉。
- **跟进命令。** `wait_for_run` 等待命令结束,或等待输出中出现指定文本(例如服务器的“正在监听”一行);`read_run_output` 从任意位置读取输出;`get_run_status` 报告命令是在运行、成功、失败、崩溃,还是被智能体或你停止;`send_run_input` 向命令的输入写入文本和 Enter,Ctrl+C 会停止命令,Ctrl+D 结束它的输入。
- **你可以随时干预。** 你在标签输入行中输入的内容、重新运行、停止和关闭标签,都会连同时间一起告诉智能体。正在进行的等待会立即返回,并建议智能体停下手头的工作,回复你已收到干预,等你确认后再继续。
- **已结束的标签会置灰。** 已结束的标签超过 **显示的已结束智能体标签数**(默认 1)时,最旧的会移入标签栏末尾的归档,显示为 **归档(n)**。归档超过 **保留的已归档智能体标签数**(默认 999)时,最旧的会被关闭。正在运行的命令永远不会被归档或关闭。这两项设置位于 **设置 › 智能体 › IDE MCP 服务器**。
插件可以添加更多工具,它们会出现在同一个列表中。标记为破坏性操作的插件工具同样会先请求确认。
## 安全
- **未受信任的项目** 只开放只读工具。写文件、重命名、重新格式化、构建或运行的工具都要求项目已被信任。
- **运行和构建会先询问。** `execute_run_configuration`、`build_project` 和 `run_command` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
- **运行和构建会先询问。** `execute_run_configuration` 和 `build_project` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
- 会写入的工具拒绝项目之外的路径。
## 限制
-16
View File
@@ -11,14 +11,6 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${gh}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${gh}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
};
@@ -29,14 +21,6 @@ describe("mirrorManifest", () => {
platforms: {
"darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` },
"windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` },
"windows-x86_64-portable": {
signature: "sig-portable",
url: `${cnb}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
},
"windows-aarch64-portable": {
signature: "sig-portable-arm",
url: `${cnb}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
},
},
});
});
-77
View File
@@ -1,77 +0,0 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { afterEach, expect, it, vi } from "vitest";
const workflow = readFileSync(new URL("../.github/workflows/release-build.yml", import.meta.url), "utf8").replaceAll(
"\r\n",
"\n",
);
const job = workflow.split("\n updater-manifest:")[1]!;
const script = job.split(" script: |\n")[1]!.replace(/^ {12}/gm, "");
const run = new Function("github", "context", "core", `return (async () => {${script}\n})();`);
afterEach(() => vi.unstubAllEnvs());
it.each(["v0.1.42", "v0.1.43-nightly.20261112"])(
"publishes signed portable updates for both Windows architectures alongside the installers (%s)",
async (tag) => {
vi.stubEnv("TAG", tag);
vi.stubEnv("RELEASE_ID", "42");
const version = tag.slice(1);
const nightly = tag.includes("-nightly.");
const bundles = [
"OpenIDEA_aarch64.AppImage",
"OpenIDEA_arm64.deb",
"OpenIDEA.aarch64.rpm",
"OpenIDEA_amd64.AppImage",
"OpenIDEA_amd64.deb",
"OpenIDEA.x86_64.rpm",
"OpenIDEA_aarch64.app.tar.gz",
"OpenIDEA_x64.app.tar.gz",
"OpenIDEA_arm64_en-US.msi",
"OpenIDEA_arm64-setup.exe",
"OpenIDEA_x64_en-US.msi",
"OpenIDEA_x64-setup.exe",
`OpenIDEA_${version}_windows_x64_portable.zip`,
`OpenIDEA_${version}_windows_arm64_portable.zip`,
].filter((name) => !nightly || (!name.endsWith(".msi") && !name.endsWith(".rpm")));
const assets = bundles.flatMap((name, id) => [
{ name, id: id * 2 },
{ name: `${name}.sig`, id: id * 2 + 1 },
]);
const upload = vi.fn();
const failed = vi.fn();
await run(
{
paginate: async () => assets,
rest: {
repos: {
listReleaseAssets: vi.fn(),
getRelease: async () => ({ data: { body: "Release notes" } }),
getReleaseAsset: async ({ asset_id }: { asset_id: number }) => ({
data: Buffer.from(`signature-${asset_id}\n`),
}),
uploadReleaseAsset: upload,
},
},
},
{ repo: { owner: "l0ng-ai", repo: "openidea" } },
{ notice: vi.fn(), info: vi.fn(), setFailed: failed },
);
expect(failed).not.toHaveBeenCalled();
const manifest = JSON.parse(upload.mock.calls[0]![0].data);
for (const [arch, filename] of [
["x86_64", "x64"],
["aarch64", "arm64"],
]) {
const name = `OpenIDEA_${version}_windows_${filename}_portable.zip`;
const sig = assets.find((a) => a.name === `${name}.sig`)!;
expect(manifest.platforms[`windows-${arch}-portable`]).toEqual({
signature: `signature-${sig.id}`,
url: `https://github.com/l0ng-ai/openidea/releases/download/${tag}/${name}`,
});
expect(manifest.platforms[`windows-${arch}`].url).toContain(nightly ? "-setup.exe" : "_en-US.msi");
expect(manifest.platforms[`windows-${arch}-nsis`].url).toContain("-setup.exe");
}
},
);
-31
View File
@@ -22,37 +22,6 @@ 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,15 +140,6 @@ pub fn mcp_ui_ready(window: Window, state: State<'_, AppState>, ready: bool) ->
Ok(())
}
/// The user closed the Run tool window tab of an agent command (`run_command`).
#[tauri::command]
#[specta::specta]
pub fn mcp_agent_run_closed(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<()> {
let project = state.project(window.label())?;
state.host.mcp_agent_runs.closed(&project.root, &name);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_respond(
-2
View File
@@ -74,7 +74,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
file_dialog::gtk_pick_paths,
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_agent_run_closed,
mcp::mcp_server_status,
mcp::mcp_set_open_editors,
mcp::mcp_set_focused_editor,
@@ -188,7 +187,6 @@ 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,
+5 -19
View File
@@ -464,15 +464,7 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
.collect::<IdeResult<Vec<_>>>()?;
let proxy = ide_core::proxy_settings::proxy();
let mut builder = app.updater_builder();
if let Some(target) = crate::updates::portable_target(
std::env::consts::OS,
std::env::consts::ARCH,
tauri::utils::platform::bundle_type(),
) {
builder = builder.target(target);
}
builder
app.updater_builder()
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
.endpoints(endpoints)
.map_err(|e| IdeError::invalid(e.to_string()))?
@@ -619,16 +611,10 @@ pub async fn app_install_update(
_ = token.cancelled() => return Err(IdeError::cancelled()),
};
let _ = on_progress.send(UpdateProgress::Installing);
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
if update.target.ends_with("-portable") {
let exe = tauri::utils::platform::current_exe()?;
return ide_tools::portable_update::install(&exe, &bytes);
}
update.install(bytes).map_err(|e| IdeError::internal(e.to_string()))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
tauri::async_runtime::spawn_blocking(move || update.install(bytes))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::internal(e.to_string()))
}
.await;
state.host.end_request(request);
-6
View File
@@ -176,12 +176,6 @@ pub fn run() {
})
.setup(move |app| {
builder.mount_events(app);
#[cfg(windows)]
tauri::async_runtime::spawn_blocking(|| {
if let Ok(exe) = tauri::utils::platform::current_exe() {
ide_tools::portable_update::cleanup(&exe);
}
});
// An unusable directory variable stops the start with a message, never a silent fallback.
let identifier = app.config().identifier.clone();
let local_data_dir = local_data_dir.and_then(|dir| {
-19
View File
@@ -11,11 +11,6 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tokio::sync::Notify;
/// Tauri patches the installer copies; the raw exe shipped in the portable zip keeps no bundle type.
pub fn portable_target(os: &str, arch: &str, bundle: Option<tauri::utils::config::BundleType>) -> Option<String> {
(os == "windows" && bundle.is_none()).then(|| format!("windows-{arch}-portable"))
}
#[derive(Default)]
pub struct Updates {
started: AtomicBool,
@@ -168,20 +163,6 @@ fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet<String>
mod tests {
use super::*;
#[test]
fn only_unbundled_windows_builds_select_portable_updates() {
use tauri::utils::config::BundleType;
for arch in ["x86_64", "aarch64"] {
assert_eq!(portable_target("windows", arch, None), Some(format!("windows-{arch}-portable")));
for bundle in [BundleType::Msi, BundleType::Nsis] {
assert_eq!(portable_target("windows", arch, Some(bundle)), None);
}
for os in ["linux", "macos"] {
assert_eq!(portable_target(os, arch, None), None);
}
}
}
#[test]
fn notifications_only_target_live_owners_that_have_subscribed() {
let live = vec!["main".into(), "project-2".into()];
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost; frame-ancestors 'none'"
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
}
},
"bundle": {
+2 -21
View File
@@ -17,8 +17,6 @@ export const commands = {
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
/** The user closed the Run tool window tab of an agent command (`run_command`). */
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
mcpSetFocusedEditor: (editor: {
@@ -282,12 +280,6 @@ 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. */
@@ -7191,10 +7183,6 @@ export type McpServerSettings = {
* (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
*/
runWithoutConfirmation?: boolean,
/** Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived. */
agentTabLimit?: number,
/** Archived agent tabs kept; older ones are closed. */
agentArchiveLimit?: number,
};
/** What Settings › Agents › MCP Server shows. */
@@ -7216,12 +7204,7 @@ export type McpServerStatus = {
/** Transport of an MCP server configured in Settings › Agents › MCP Servers. */
export type McpTransport = "stdio" | "http" | "sse";
/**
* What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
* line) in the Run tool window's agent tab `name`, on pipes, answered once the process
* started or failed to.
*/
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null } | { type: "agentRun"; name: string; command: string; cwd: IpcPath; env: { [key in string]: string } };
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null };
export type MemoryCategory =
/** The backend and its WebView processes. */
@@ -8130,9 +8113,7 @@ export type PomGotoKind =
/** A `<dependencyManagement>` entry providing the dependency's version. */
"managedDependency" |
/** A `<pluginManagement>` entry providing the plugin's version. */
"managedPlugin" |
/** The POM of the artifact itself: a project module's, else the local repository's. */
"artifactPom";
"managedPlugin";
/** A 0-based line / UTF-16 column position. */
export type Pos = {
@@ -1,104 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { java } from "@codemirror/lang-java";
import { history, undo } from "@codemirror/commands";
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
let view: EditorView | null = null;
beforeEach(() => void vi.useFakeTimers());
afterEach(() => {
view?.destroy();
view = null;
vi.useRealTimers();
});
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
}),
parent: document.body,
});
return view;
}
/** Types `text` at `at` the way a keystroke does. */
const type = (v: EditorView, at: number, text: string) =>
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
/** What a full rebuild of the same text renders, in a fresh editor state. */
function fullRebuild(doc: string): Array<[number, number]> {
return renderedRanges(
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
);
}
const pause = () => vi.advanceTimersByTime(300);
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
const v = open();
const [before] = renderedRanges(v.state);
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
// comment at once. Typing only moves it along until the pause.
type(v, v.state.doc.line(2).from + 2, "/*");
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("renders a pasted comment at once", () => {
const v = open();
v.dispatch({
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
userEvent: "input.paste",
});
expect(renderedRanges(v.state)).toHaveLength(2);
});
it("brings a deleted comment back rendered at once on undo", () => {
const v = open(DOC, [history()]);
const line = v.state.doc.line(4);
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
expect(renderedRanges(v.state)).toHaveLength(0);
undo(v);
expect(renderedRanges(v.state)).toHaveLength(1);
});
it("rebuilds at once for a keystroke on a comment's line", () => {
const v = open();
// Code after the closing marker: the comment no longer has its line to itself.
type(v, v.state.doc.line(4).to, " int x;");
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("still shows the source of a comment the caret lands in after fast typing", () => {
const v = open();
type(v, v.state.doc.line(2).from + 2, "xx");
const at = v.state.doc.toString().indexOf("Books");
v.dispatch({ selection: EditorSelection.cursor(at) });
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
// A seeded generator, so a failure replays.
let seed = 7;
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const lines = ["class A {"];
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
lines.push("}");
const v = open(lines.join("\n"));
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
for (let i = 0; i < 400; i++) {
const at = Math.floor(next() * (v.state.doc.length + 1));
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
else type(v, at, keys[Math.floor(next() * keys.length)]!);
if (next() < 0.05) pause();
}
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
});
+33 -93
View File
@@ -25,13 +25,8 @@
* Off, which is the default, the icon still shows over a comment, so one comment can be
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
* comments and none is flipped, its comments are found only once typing pauses
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
* only if the file has doc comments.
*
* On, typing costs about as little: a keystroke away from every comment's lines only
* moves the rendered comments along with the text, and the file is scanned again once
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
* once, so the comments it brings in render at once.
*/
import {
EditorState,
@@ -41,7 +36,6 @@ import {
RangeSet,
StateEffect,
StateField,
type ChangeSet,
type Extension,
type Transaction,
} from "@codemirror/state";
@@ -375,55 +369,11 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
interface DocsView {
decorations: DecorationSet;
/** Every doc comment, rendered or not, in document order. */
/** Every doc comment, rendered or not. */
comments: ReadonlyArray<PlacedComment>;
/**
* Moved along with keystrokes since the last scan, instead of found again: what the
* parser made of them may differ, until `deferredScanner` rebuilds.
*/
stale: boolean;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
/** Asks for the scan a stale view is waiting for. */
const rescanDocs = StateEffect.define<null>();
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
return comments.map((c) => ({
...c,
from: changes.mapPos(c.from, 1),
to: changes.mapPos(c.to, -1),
line: changes.mapPos(c.line, 1),
end: changes.mapPos(c.end, -1),
}));
}
/**
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
* the file one), which the scan after the pause takes in.
*/
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
const { comments } = view;
let touches = false;
tr.changes.iterChangedRanges((fromA, toA) => {
// The first comment that does not end before the change.
let lo = 0;
let hi = comments.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (comments[mid]!.end < fromA) lo = mid + 1;
else hi = mid;
}
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
});
return !touches;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
/**
* Rendered comments by their source, shared by every editor, least recently used first.
@@ -492,7 +442,7 @@ function build(state: EditorState): DocsView {
),
);
}
return { decorations: Decoration.set(decorations), comments, stale: false };
return { decorations: Decoration.set(decorations), comments };
}
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
@@ -526,7 +476,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
/**
* The doc comments of a file that renders none, for their icons. Rescanning the file on
* each keystroke would make typing in a large file several times slower for a feature
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
* found are mapped through the edits. Empty while the file renders comments: `docsField`
* has them then.
*/
@@ -537,29 +487,32 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
// Switched off: the comments rendered a moment ago are known only to the last scan.
if (active(tr.startState)) return placeComments(tr.state);
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
return tr.docChanged ? mapComments(value, tr.changes) : value;
if (!tr.docChanged) return value;
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
return value.map((c) => ({
...c,
from: map(c.from, 1),
to: map(c.to, -1),
line: map(c.line, 1),
end: map(c.end, -1),
}));
},
});
/**
* Scans the file once edits and the parser have paused: a file that renders no comments,
* for their icons; one that does, when keystrokes have only moved its comments along.
*/
const deferredScanner = ViewPlugin.fromClass(
/** Scans a file that renders no comments once the edits and the parser have paused. */
const passiveScanner = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | null = null;
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
// Rendering and up to date: the view was just built, nothing is waiting.
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
const { state } = this.view;
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
}, RESCAN_DELAY);
if (active(update.state)) return this.cancel();
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
}, PASSIVE_SCAN_DELAY);
}
}
cancel() {
if (this.timer !== null) clearTimeout(this.timer);
@@ -571,8 +524,8 @@ const deferredScanner = ViewPlugin.fromClass(
},
);
/** How long edits and parsing must pause before the file is scanned again. */
const RESCAN_DELAY = 300;
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
const PASSIVE_SCAN_DELAY = 300;
/** Every doc comment of the file, as last found. */
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
@@ -634,26 +587,13 @@ const docsField = StateField.define<DocsView>({
create: build,
update(value, tr) {
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
if (
const rebuild =
tr.docChanged ||
tr.reconfigured ||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
)
return build(tr.state);
if (tr.docChanged) {
// A keystroke away from every comment moves them along; the scan waits for a pause.
if (active(tr.state) && typedOutsideComments(value, tr))
return {
decorations: value.decorations.map(tr.changes),
comments: mapComments(value.comments, tr.changes),
stale: true,
};
return build(tr.state);
}
// The parser finishing more of the file can bring comments it had not reached: built
// at once, so a file opens with the comments on screen rendered, unless keystrokes
// already have the scan waiting for a pause.
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
return value;
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
// The parser finishing more of the file can bring comments it had not reached.
syntaxTree(tr.startState) !== syntaxTree(tr.state);
return rebuild ? build(tr.state) : value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
});
@@ -898,7 +838,7 @@ export const renderedDocsExtension: Extension = [
flipped,
docsField,
passiveDocs,
deferredScanner,
passiveScanner,
hovered,
hoverTracker,
showSourceAtCaret,
+3 -3
View File
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
] as MenuItem[])
: ([
{ kind: "separator" },
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
openInMenu(slot.model.path),
{ 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, line?: number): MenuItem {
function openInMenu(path: string): MenuItem {
return {
label: "Open In",
submenu: [
@@ -1971,7 +1971,7 @@ function openInMenu(path: string, line?: number): MenuItem {
: []),
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
{ kind: "separator" },
...menuGroupItems("openIn", { paths: [path], line }),
...menuGroupItems("openIn", { paths: [path] }),
],
};
}
+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 { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { 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 readClipboardPlainText();
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
hint("Cannot read the clipboard");
return;
+8 -8
View File
@@ -1,5 +1,5 @@
/**
* Settings › Tools › Agents › Command Permissions: whitelist (auto-approve)
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
* and blacklist (auto-reject) command patterns evaluated on agent permission requests.
*/
import { useMemo, useState } from "react";
@@ -47,18 +47,18 @@ export function AgentPolicyPage() {
};
return (
<section className={styles.body} aria-label="Command Permissions">
<section className={styles.body} aria-label="Command Execution Policy">
<p className={styles.notice}>
<T>
Allowed commands run without asking, with or without extra arguments. Blocked commands are always rejected,
even if allowed, and support * and ? wildcards. Commands with substitutions, redirections or variable
assignments always ask.
Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands
with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win
over allowed ones; they support the * and ? wildcards.
</T>
</p>
<div className={styles.grid}>
<div className={styles.column}>
<div className={styles.columnTitle}>
<T>Allowed Commands</T>
<T>Allowed Commands (Whitelist)</T>
</div>
<div className={styles.items}>
{policy.allowedCommands.map((pattern) => (
@@ -80,7 +80,7 @@ export function AgentPolicyPage() {
value={allowDraft}
onChange={(e) => setAllowDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addAllowed()}
placeholder={tx("e.g. cargo test")}
placeholder={tx("e.g. cargo test*")}
aria-label="Allowed command pattern"
/>
<Button size="small" aria-label="Add allowed command" onClick={addAllowed}>
@@ -90,7 +90,7 @@ export function AgentPolicyPage() {
</div>
<div className={styles.column}>
<div className={styles.columnTitle}>
<T>Blocked Commands</T>
<T>Blocked Commands (Blacklist)</T>
</div>
<div className={styles.items}>
{policy.blockedCommands.map((pattern) => (
+3 -3
View File
@@ -32,7 +32,7 @@ function FormWrapper({ children }: { children: ReactNode }) {
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
}
describe("Command Permissions sub-page", () => {
describe("Command Execution Policy sub-page", () => {
it("registers under Agents group in settings", () => {
const dispose = activateSettingsPages();
try {
@@ -40,7 +40,7 @@ describe("Command Permissions sub-page", () => {
const agentsGroup = tree.find((n) => n.title === AGENTS_GROUP);
expect(agentsGroup).toBeDefined();
const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE);
expect(policyNode?.title).toBe("Command Permissions");
expect(policyNode?.title).toBe("Command Execution Policy");
// A project's settings file can't decide what agents run without asking.
expect(policyNode?.page?.projectScope).not.toBe(true);
} finally {
@@ -56,7 +56,7 @@ describe("Command Permissions sub-page", () => {
</FormWrapper>,
);
const policy = screen.getByRole("region", { name: "Command Permissions" });
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
+1 -1
View File
@@ -59,7 +59,7 @@ export function activateSettingsPages(): () => void {
}),
registerSettingsPage({
id: POLICY_PAGE,
title: "Command Permissions",
title: "Command Execution Policy",
group: AGENTS_GROUP,
order: 490,
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
+1 -1
View File
@@ -13,7 +13,7 @@ export const REGISTRY_PAGE = "agents.list";
export const AGENTS_GROUP = "Agents";
/** Settings › Agents › External MCP Servers: User and Workspace MCP configurations. */
export const MCP_PAGE = "agents.mcpServers";
/** Settings › Agents › Command Permissions. */
/** Settings › Agents › Command Execution Policy. */
export const POLICY_PAGE = "agents.policy";
export const sessionKey = (task: string) => `agent:${task}`;
+2 -3
View File
@@ -11,7 +11,6 @@ import { quitApp } from "./closeGuard";
import { guessOs } from "./host";
import { showKeymapReference } from "./KeymapReference";
import { checkForUpdates } from "./updates";
import { readClipboardPlainText } from "@/platform/clipboard";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
@@ -51,8 +50,8 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
}
if (command === "selectAll" && selectReadingPane(document.activeElement)) return;
if (command === "paste") {
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
if (command === "paste" && navigator.clipboard?.readText) {
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
return;
}
document.execCommand(command);
@@ -108,11 +108,9 @@ describe("POM artifact elements", () => {
expect(inArtifactElement(pom, pom.indexOf("example-lib</artifactId>"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true);
// Inside `<parent>`: its artifact is the parent POM.
expect(inArtifactElement(pom, pom.indexOf("<parent>") + "<parent>".length + 1)).toBe(true);
});
it("sees no artifact element outside dependencies, plugins and the parent", () => {
it("sees no artifact element outside dependencies and plugins", () => {
expect(inArtifactElement(pom, pom.indexOf("<properties>"))).toBe(false);
expect(inArtifactElement(pom, pom.indexOf("<parent>"))).toBe(false);
// `<plugins>` and `<dependencyManagement>` are not a `<plugin>` / `<dependency>`.
+5 -6
View File
@@ -4,8 +4,7 @@
* along the parent chain (a parent POM outside the project opens from the local
* repository, read-only as a plain file) — and a dependency / plugin whose version a
* `<dependencyManagement>` / `<pluginManagement>` entry provides opens that managing
* declaration; otherwise a dependency, plugin or `<parent>` opens the artifact's own POM
* (a project module's, else the local repository's). The backend resolves both with the build model's POM reader and parent
* declaration. The backend resolves both with the build model's POM reader and parent
* resolver; here only cheap eligibility is computed (a reference span, an artifact
* element), so the ⌘-hover underline stays out of the rest of the file, and nothing
* resolves to no jump at all: an unresolvable property or parent never navigates
@@ -35,12 +34,12 @@ function inside(name: string, text: string, at: number): boolean {
}
/**
* Whether `at` may sit in a `<dependency>` / `<plugin>` / `<parent>` element: an
* eligibility test only (a `<dependency>` inside a comment counts too) — what the
* element resolves to is the backend's answer.
* Whether `at` may sit in a `<dependency>` / `<plugin>` element: an eligibility test
* only (a `<dependency>` inside a comment counts too) — whether the element's version
* actually comes from a management entry is the backend's answer.
*/
export function inArtifactElement(text: string, at: number): boolean {
return inside("dependency", text, at) || inside("plugin", text, at) || inside("parent", text, at);
return inside("dependency", text, at) || inside("plugin", text, at);
}
/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */
+1 -2
View File
@@ -6,7 +6,6 @@ import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
import { readClipboardPlainText } from "@/platform/clipboard";
const act = (
action: string,
@@ -49,7 +48,7 @@ function dataSourceItems(): MenuItem[] {
async function sourceFromClipboard() {
let text = "";
try {
text = await readClipboardPlainText();
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
// Unreadable clipboard: reported below like an unknown one.
}
+4 -13
View File
@@ -10,9 +10,6 @@ import { gutterIconMarkup, showContextMenu } from "@/ui";
import { emptyGutterSpacer, gutterSpacer } from "@/editor/api";
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { appendGeneratedRequests } from "./generatedRequests";
import { headerCompletion } from "./headers";
import { sourceReader } from "./spring/editor";
import { findHost } from "./spring/request";
import { METHODS } from "./language";
import styles from "./HttpClient.module.css";
@@ -20,15 +17,13 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
export function renderEndpoint(e: EndpointEntry, host = "http://localhost:8080"): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} ${host}${endpointUrl(e.url)}\n`;
export function renderEndpoint(e: EndpointEntry): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
}
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
// The server address of the endpoint's module (`server.port`, context path), as for Spring mappings.
const host = await findHost(endpoint.path, useProject.getState().project?.root ?? null, sourceReader);
await appendGeneratedRequests([renderEndpoint(endpoint, host)]);
await appendGeneratedRequests([renderEndpoint(endpoint)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
@@ -99,11 +94,7 @@ class EndpointMarker extends GutterMarker {
const endpointSpacer = gutterSpacer(styles.runMarker ?? "");
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.language === "http")
return EditorState.languageData.of(() => [
{ openideaCompletion: endpointCompletion },
{ openideaCompletion: headerCompletion },
]);
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.virtual) return [];
return [
marks,
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { headerTarget } from "./headers";
const at = (text: string) => headerTarget(text.replace("|", ""), text.indexOf("|"));
describe("header completion", () => {
it("completes header names on the lines after the request line", () => {
expect(at("POST http://localhost:8080/users\nCon|")).toEqual({ kind: "name", from: 33, prefix: "Con" });
expect(at("### Create\n# @name create\nPOST {{host}}/users\nAccept: */*\n|")).toMatchObject({
kind: "name",
prefix: "",
});
expect(at("http://localhost/a\nX-Re|")).toMatchObject({ kind: "name", prefix: "X-Re" });
});
it("completes the values of a header", () => {
expect(at("POST http://x/a\nContent-Type: app|")).toEqual({
kind: "value",
name: "Content-Type",
from: 30,
prefix: "app",
});
expect(at("GET http://x/a\nAuthorization:|")).toMatchObject({ kind: "value", name: "Authorization", prefix: "" });
});
it("stays out of request lines, bodies, comments and blocks without a request", () => {
expect(at("POS|")).toBeNull();
expect(at('POST http://x/a\nContent-Type: application/json\n\n{"a|')).toBeNull();
expect(at("POST http://x/a\n# Acc|")).toBeNull();
expect(at("@host = http://x\nAcc|")).toBeNull();
expect(at("POST http://x/a\n\n###\nAcc|")).toBeNull();
});
});
-131
View File
@@ -1,131 +0,0 @@
/** Header name and value completion in `.http` request blocks (the lines after the request line). */
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { METHODS } from "./language";
const REQUEST_LINE = new RegExp(`^\\s*(?:(?:${METHODS.join("|")})\\s+\\S|https?://|\\{\\{)`);
const COMMENT = /^\s*(?:#|\/\/)/;
const HEADERS = [
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Authorization",
"Cache-Control",
"Connection",
"Content-Disposition",
"Content-Encoding",
"Content-Language",
"Content-Length",
"Content-Type",
"Cookie",
"Date",
"Expect",
"Forwarded",
"From",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
"Max-Forwards",
"Origin",
"Pragma",
"Proxy-Authorization",
"Range",
"Referer",
"TE",
"Upgrade",
"User-Agent",
"Via",
"X-Correlation-ID",
"X-CSRF-Token",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Request-ID",
"X-Requested-With",
];
const MEDIA_TYPES = [
"application/json",
"application/xml",
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
"text/html",
"text/xml",
"text/csv",
"application/octet-stream",
"application/graphql",
"application/x-ndjson",
"application/problem+json",
"*/*",
];
const VALUES: Record<string, string[]> = {
accept: MEDIA_TYPES,
"content-type": MEDIA_TYPES,
"accept-encoding": ["gzip, deflate, br", "gzip", "deflate", "br", "identity"],
"content-encoding": ["gzip", "deflate", "br"],
"accept-charset": ["utf-8", "iso-8859-1"],
"accept-language": ["en-US", "en", "zh-CN", "zh"],
authorization: ["Bearer ", "Basic "],
"proxy-authorization": ["Bearer ", "Basic "],
"cache-control": ["no-cache", "no-store", "max-age=0", "must-revalidate"],
pragma: ["no-cache"],
connection: ["keep-alive", "close", "Upgrade"],
upgrade: ["websocket", "h2c"],
expect: ["100-continue"],
"x-requested-with": ["XMLHttpRequest"],
"content-disposition": ["attachment", "inline", 'form-data; name=""'],
"access-control-request-method": METHODS,
};
export type HeaderTarget =
| { kind: "name"; from: number; prefix: string }
| { kind: "value"; name: string; from: number; prefix: string };
/** What is being typed at `pos`: a header name or a header's value, or null outside a request's headers. */
export function headerTarget(text: string, pos: number): HeaderTarget | null {
const before = text.slice(0, pos);
const lineStart = before.lastIndexOf("\n") + 1;
const line = before.slice(lineStart);
if (COMMENT.test(line)) return null;
const lines = (
before
.slice(0, lineStart)
.split(/^\s*###.*$/m)
.at(-1) ?? ""
)
.split("\n")
.slice(0, -1);
let request = false;
for (const l of lines) {
if (COMMENT.test(l)) continue;
if (!request) request = REQUEST_LINE.test(l);
// A blank line ends the headers; the body follows.
else if (!l.trim()) return null;
}
if (!request) return null;
const colon = line.indexOf(":");
if (colon < 0) return /^[\w-]*$/.test(line) ? { kind: "name", from: lineStart, prefix: line } : null;
const prefix = line.slice(colon + 1).replace(/^\s+/, "");
return { kind: "value", name: line.slice(0, colon).trim(), from: pos - prefix.length, prefix };
}
export function headerCompletion(context: CompletionContext): CompletionResult | null {
const target = headerTarget(context.state.doc.toString(), context.pos);
if (!target) return null;
if (target.kind === "name") {
if (!target.prefix && !context.explicit) return null;
const options: Completion[] = HEADERS.map((name) => ({ label: name, type: "property", apply: `${name}: ` }));
return { from: target.from, options, validFor: /^[\w-]*$/ };
}
const values = VALUES[target.name.toLowerCase()];
if (!values) return null;
return { from: target.from, options: values.map((v) => ({ label: v, type: "constant" })) };
}
+3 -4
View File
@@ -21,7 +21,7 @@ export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
export const sourceReader: SourceReader = {
const reader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
@@ -29,7 +29,6 @@ export const sourceReader: SourceReader = {
return null;
}
},
filesNamed: (name) => call(commands.findFilesNamed(name)),
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
@@ -44,12 +43,12 @@ export async function generateRequests(sourcePath: string, text: string, targets
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, sourceReader),
findHost(sourcePath, root, reader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
sourceReader,
reader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
+37 -100
View File
@@ -115,18 +115,9 @@ export function skeleton(type: string, lookup: (name: string) => ClassDecl | nul
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
// Superclass properties first, as Jackson writes them.
const chain = [cls];
for (let c = cls; c.superclass && chain.length <= MAX_SUPERCLASSES;) {
const parent = lookup(bareType(c.superclass).replace(/<.*$/, ""));
if (!parent || chain.includes(parent)) break;
chain.unshift((c = parent));
}
const obj: { [k: string]: Json } = {};
for (const c of chain) {
for (const p of c.ctorParams) if (!(p.name in obj)) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of c.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
}
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
return obj;
}
@@ -184,52 +175,34 @@ export function appendRequests(existing: string, blocks: string[]): { insert: st
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
/** The project's files called `name` (from the index). */
filesNamed?(name: string): Promise<string[]>;
}
/** How many superclasses of a body type are followed for their fields. */
const MAX_SUPERCLASSES = 5;
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files (of `profile`) for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null, profile?: string): string[] {
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
const norm = sourcePath.replace(/\\/g, "/");
const at = norm.lastIndexOf("/src/main/");
const roots = at >= 0 ? [norm.slice(0, at)] : [];
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
return roots.flatMap((r) =>
["properties", "yml", "yaml"].map((x) => `${r}/src/main/resources/application${profile ? `-${profile}` : ""}.${x}`),
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
const read = async (paths: string[]) => {
const configs: Map<string, string>[] = [];
for (const path of paths) {
const text = await fs.read(path).catch(() => null);
if (text !== null) configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return configs;
};
const base = await read(configCandidates(sourcePath, projectRoot));
// `spring.profiles.active=dev`: `application-dev.*` overrides the base files (the last profile wins).
const active = base.map((c) => resolved(c.get("spring.profiles.active"))).find(Boolean);
const profiles = (active ?? "")
.split(",")
.map((p) => p.trim())
.filter((p) => /^[\w.-]+$/.test(p))
.reverse();
const profiled = await read(profiles.flatMap((p) => configCandidates(sourcePath, projectRoot, p)));
return hostFromConfig([...profiled, ...base]);
const configs: Map<string, string>[] = [];
for (const path of configCandidates(sourcePath, projectRoot)) {
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return hostFromConfig(configs);
}
/**
* Body types of `endpoints`: classes of the controller's file, then `<Type>.java|.kt` beside
* it or where its import points (same source root), else the project file of that name in the
* imported (or same / wildcard-imported) package, e.g. a DTO in another Maven module. Their
* superclasses are loaded too.
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
*/
export async function bodyLookup(
sourcePath: string,
@@ -238,78 +211,42 @@ export async function bodyLookup(
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
/** Where each class was read: names in its declarations resolve from that file. */
const origins = new Map<string, Origin>();
const add = (o: Origin) => {
for (const c of allClasses(o.file))
if (!known.has(c.name)) {
known.set(c.name, c);
origins.set(c.name, o);
}
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
};
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
add(origin);
add(file);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
const readAt = async (path: string) => {
const text = await fs.read(path).catch(() => null);
if (text === null) return null;
const loaded = { path, file: parseSource(text) };
add(loaded);
return loaded;
};
/** Loads `name` as seen from `from` (its imports, then its package). */
const load = async (name: string, from: Origin) => {
if (known.has(name)) return origins.get(name) ?? null;
if (!/^[A-Z]\w*$/.test(name)) return null;
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const packages = imported
? [imported.split(".").slice(0, -1).join(".")]
: [from.file.packageName, ...from.file.imports.filter((i) => i.endsWith(".*")).map((i) => i.slice(0, -2))];
const root = sourceRoot(from.path, from.file);
const dirs = new Set([...packages.map((p) => (p ? `${root}/${p.replace(/\./g, "/")}` : root)), dirname(from.path)]);
for (const d of dirs)
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
for (const x of ext) {
const loaded = await readAt(`${d}/${name}${x}`);
if (loaded) return loaded;
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
}
// Another source root or module: the project's files of that name, in one of those packages.
if (!fs.filesNamed) return null;
const suffixes = packages.map((p) => `/${p ? `${p.replace(/\./g, "/")}/` : ""}${name}`);
for (const x of ext) {
const paths = (await fs.filesNamed(`${name}${x}`).catch(() => [])).map((p) => p.replace(/\\/g, "/"));
const path = paths.find((p) => suffixes.some((s) => p.endsWith(`${s}${x}`)));
const loaded = path ? await readAt(path) : null;
if (loaded) return loaded;
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
/** `name`, its superclasses, and the property types of each, one level down. */
const loadWithProperties = async (name: string, from: Origin, depth: number) => {
const seen = new Set<string>();
let [current, at]: [string | undefined, Origin | null] = [name, from];
while (current && at && !seen.has(current) && seen.size < MAX_SUPERCLASSES) {
seen.add(current);
const loaded = await load(current, at);
const c = known.get(current);
if (!c || !loaded) break;
if (depth > 0)
for (const p of [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)])
await loadWithProperties(typeName(p.type), loaded, depth - 1);
[current, at] = [c.superclass && typeName(c.superclass), loaded];
}
};
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) await loadWithProperties(n, origin, 1);
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
return (name) => known.get(name) ?? null;
}
interface Origin {
path: string;
file: SourceFile;
}
-28
View File
@@ -65,8 +65,6 @@ export interface ClassDecl {
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
/** The class it extends (`extends Base<T>` / Kotlin `: Base()`), as written. */
superclass?: string;
}
export interface SourceFile {
@@ -222,26 +220,6 @@ class Reader {
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** A type name at `i` (`a.b.Base<T>`): its text and the index after it. */
typeName(i: number, to: number): { name: string; end: number } | null {
let j = i;
while (j < to && this.toks[j]?.kind === "id" && (j === i || this.is(j - 1, "."))) {
j++;
if (!this.is(j, ".") || this.toks[j + 1]?.kind !== "id") break;
j++;
}
if (j === i) return null;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
return { name: joined(this.toks.slice(i, j)), end: j };
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
@@ -437,12 +415,6 @@ class Reader {
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
// Java `extends Base`; Kotlin `: Base(…)` (a supertype called with arguments is the class).
const javaSuper = this.toks[j]!.kind === "id" && this.toks[j]!.text === "extends";
if (!cls.superclass && (javaSuper || this.is(j, ":") || this.is(j, ","))) {
const type = this.typeName(j + 1, to);
if (type && (javaSuper || this.is(type.end, "("))) cls.superclass = type.name;
}
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
@@ -252,19 +252,6 @@ describe("server address", () => {
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
});
it("reads the active profiles' files over the base ones", async () => {
const files: Record<string, string> = {
"/ws/api/src/main/resources/application.yml":
"server:\n port: 8001\n servlet:\n context-path: /api\nspring:\n profiles:\n active: ${PROFILE:local}, dev\n",
"/ws/api/src/main/resources/application-local.properties": "server.port=7000\n",
"/ws/api/src/main/resources/application-dev.yml": "server:\n port: 9100\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:9100/api");
delete files["/ws/api/src/main/resources/application-dev.yml"];
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:7000/api");
});
});
describe("body types from other files", () => {
@@ -284,41 +271,6 @@ describe("body types from other files", () => {
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
it("finds a DTO in another module through the index, with its superclass fields", async () => {
const files: Record<string, string> = {
"/ws/common/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\nimport com.example.base.BaseEntity;\n@Data\npublic class CreateUser extends BaseEntity<Long> implements Serializable {\n private String name;\n private Address address;\n}\n",
"/ws/common/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic class Address { private String city; }\n",
"/ws/base/src/main/java/com/example/base/BaseEntity.java":
"package com.example.base;\npublic abstract class BaseEntity<ID> { private ID id; private LocalDateTime createdAt; }\n",
// Same name, other package: not the imported class.
"/ws/legacy/src/main/java/com/legacy/CreateUser.java":
"package com.legacy;\npublic class CreateUser { int old; }\n",
};
const fs: SourceReader = {
read: async (p) => files[p] ?? null,
filesNamed: async (name) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
};
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup(
"/ws/web/src/main/java/com/example/demo/web/UserController.java",
parseSource(JAVA),
eps,
fs,
);
expect(skeleton("CreateUser", lookup)).toEqual({ id: {}, createdAt: "", name: "", address: { city: "" } });
});
it("reads Java and Kotlin superclasses", () => {
const java = parseSource("class A extends com.x.Base<T> implements I { }\ninterface J extends K {}");
expect(java.classes.map((c) => c.superclass)).toEqual(["com.x.Base<T>", "K"]);
const kotlin = parseSource(
"class A(val a: Int) : Base(a), Serializable\nclass B : Serializable, Other()\nclass C : I",
);
expect(kotlin.classes.map((c) => c.superclass)).toEqual(["Base", "Other", undefined]);
});
});
describe("appending to the request file", () => {
+1 -15
View File
@@ -83,14 +83,6 @@ export function touchesManifest(paths: readonly string[]): boolean {
);
}
/** IDEA's order, whatever order the ecosystems were detected in: SDKs first, then libraries, each by name. */
function sortedGroups(groups: readonly LibraryGroup[]): LibraryGroup[] {
const rank = (g: LibraryGroup) => (SDK_KINDS.has(g.kind) ? 0 : 1);
return [...groups].sort(
(a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true }),
);
}
/** The "External Libraries" node for `groups` (`< Python 3.12 (.venv) >` › `python3.12`, `site-packages`). */
export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootContribution | null {
if (!groups.length) return null;
@@ -101,7 +93,7 @@ export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootC
// After the project, before Scratches and Consoles (IDEA's order).
order: 5,
readOnly: true,
children: sortedGroups(groups).map((g) => ({
children: groups.map((g) => ({
label: SDK_KINDS.has(g.kind) ? `< ${g.name} >` : g.name,
icon: GROUP_ICONS[g.kind] ?? "library",
children: g.roots.map((r) => ({
@@ -200,18 +192,12 @@ export function activate(): () => void {
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
// A Maven / Gradle sync changes the dependency versions without touching a manifest.
const unsubRoots = onIdeEvent("projectRootsChanged", () => {
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
return () => {
seq++;
if (request !== undefined) void commands.cancelRequest(request).catch(() => {});
clearTimeout(timer);
unsub();
unsubFs();
unsubRoots();
disposeActions();
clear();
};
+11 -39
View File
@@ -37,46 +37,18 @@ describe("External Libraries", () => {
}),
),
)!;
expect(Object.fromEntries(root.children!.map((g) => [g.label, g.icon]))).toEqual({
"< jdk >": "file-java",
nodeCore: "file-js",
nodeModules: "file-npm",
"< rust >": "file-rust",
cargo: "file-cargo",
"< go >": "file-go",
goModules: "file-gomod",
maven: "maven-project",
gradle: "file-gradle",
other: "library",
});
});
it("lists SDKs first, then libraries by name, whatever order they were detected in", () => {
const group = (name: string, kind: string) => ({ name, kind, roots: [{ name: "r", path: `/libs/${name}` }] });
const detected = [
group("Maven: org.junit:junit:4.13", "maven"),
group("temurin-21", "jdk"),
group("node_modules", "nodeModules"),
group("Python 3.14", "python"),
group("Cargo dependencies", "cargo"),
group("Gradle: com.acme:lib:1.0", "gradle"),
group("Go SDK 1.27.4", "go"),
group("Maven: com.acme:core:2.1.27", "maven"),
group("Node.js Core", "nodeCore"),
];
const labels = (groups: typeof detected) => librariesRoot(groups)!.children!.map((g) => g.label);
expect(labels(detected)).toEqual([
"< Go SDK 1.27.4 >",
"< Python 3.14 >",
"< temurin-21 >",
"Cargo dependencies",
"Gradle: com.acme:lib:1.0",
"Maven: com.acme:core:2.1.27",
"Maven: org.junit:junit:4.13",
"node_modules",
"Node.js Core",
expect(root.children?.map((g) => g.icon)).toEqual([
"file-java",
"file-js",
"file-npm",
"file-rust",
"file-cargo",
"file-go",
"file-gomod",
"maven-project",
"file-gradle",
"library",
]);
expect(labels([...detected].reverse())).toEqual(labels(detected));
});
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
+1 -23
View File
@@ -1,15 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const mocks = vi.hoisted(() => ({
respond: vi.fn(async () => ({ status: "ok", data: null })),
open: vi.fn(),
execute: vi.fn(async (_id: string, _arg?: unknown) => {}),
}));
vi.mock("@/platform/actions", async (original) => ({
...(await original<object>()),
executeActionOrThrow: mocks.execute,
}));
const mocks = vi.hoisted(() => ({ respond: vi.fn(async () => ({ status: "ok", data: null })), open: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<object>()),
commands: { mcpRespond: mocks.respond },
@@ -83,18 +75,4 @@ describe("MCP confirmations", () => {
await handleMcpRequest(8, { type: "openFile", path: "/project/b.ts", line: null, column: null });
expect(mocks.respond).toHaveBeenCalledWith(8, false, "Error: cannot open");
});
it("starts an agent command in the Run tool window and says whether it started", async () => {
const request = { type: "agentRun", name: "_agent_test", command: "npm test", cwd: "/project", env: {} } as const;
await handleMcpRequest(9, request);
expect(mocks.execute).toHaveBeenCalledWith("run.agentStart", {
name: "_agent_test",
command: "npm test",
cwd: "/project",
env: {},
});
expect(mocks.respond).toHaveBeenCalledWith(9, true, null);
mocks.execute.mockRejectedValueOnce(new Error("The command did not start"));
await handleMcpRequest(10, request);
expect(mocks.respond).toHaveBeenCalledWith(10, false, "The command did not start");
});
});
-12
View File
@@ -1,6 +1,5 @@
/** Window requests from the built-in MCP server, answered through window-scoped IPC. */
import { create } from "zustand";
import { executeActionOrThrow } from "@/platform/actions";
import { call, commands, type McpUiRequest } from "@/platform/ipc";
import { editorService } from "@/workbench/contributions";
@@ -44,17 +43,6 @@ export async function handleMcpRequest(
);
return;
}
if (request.type === "agentRun") {
// The Run tool window starts it in the agent tab; the answer says whether it started.
try {
const { name, command, cwd, env } = request;
await executeActionOrThrow("run.agentStart", { name, command, cwd, env });
await send(id, true);
} catch (error) {
await send(id, false, error instanceof Error ? error.message : String(error)).catch(() => {});
}
return;
}
try {
await editor().openFile(request.path, {
selection: request.line != null ? { line: request.line - 1, col: (request.column ?? 1) - 1 } : undefined,
+2 -2
View File
@@ -1,4 +1,4 @@
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { useEffect, useState } from "react";
import clsx from "clsx";
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
@@ -94,7 +94,7 @@ export function EnvDialog({
};
const paste = async () => {
try {
const text = await readClipboardPlainText();
const text = await navigator.clipboard.readText();
setRows((r) => pasteRows(r, text));
} catch {
/* clipboard unavailable */
-26
View File
@@ -12,32 +12,6 @@
background: none;
}
/* Archived agent tabs (`run_command`), after the run tabs. */
.agentArchive {
display: flex;
flex: none;
align-items: center;
gap: 4px;
height: 22px;
margin: 0 4px;
padding: 0 6px;
border: 0;
border-radius: var(--radius-small);
background: none;
color: var(--fg-muted);
font: inherit;
cursor: pointer;
}
.agentArchive:hover {
background: var(--bg-hover);
}
.agentArchive[data-active] {
color: var(--accent);
}
.agentArchiveCount {
font-size: var(--fs-small);
}
.panel {
flex: 1;
display: flex;
+5 -57
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { RunConfig } from "@/platform/ipc";
import type { RunTargetValid } from "@/platform/remoteSettings";
import { useAgentRunSettings, useWorkflowSettings } from "@/platform/workflowSettings";
import { EmptyState, Icon, IconButton, Menu, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
import { executeAction } from "@/platform/actions";
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
import { ConsoleModel } from "./console";
@@ -39,8 +39,6 @@ import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab }
import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate";
import { isUpdatable } from "./updatePolicies";
import { configIcon } from "./configs";
import { agentName } from "./agentConfig";
import { archivedAgentSessions, closeAgentArchive } from "./agentRuns";
import styles from "./Run.module.css";
import { tx } from "@/platform/i18n";
import { toolWindowService } from "@/workbench/contributions";
@@ -275,8 +273,6 @@ function sessionItems(sessions: readonly RunSession[], targets: readonly RunTarg
text: remoteLabel(s.config, targets),
icon: sessionIcon(s),
color: failed(s) ? "var(--danger)" : undefined,
// An agent's command that ended, however it ended.
dimmed: agentName(s.config) !== null && !isLive(s),
closable: true,
tooltip:
s.status === "finished"
@@ -312,74 +308,26 @@ export function RunTabs() {
const traces = useStackTraces((s) => s.tabs);
const runTargets = useWorkflowSettings((s) => s.runTargets);
const active = useRun((s) => s.activeSession);
const { tabLimit } = useAgentRunSettings();
const current = currentTab(sessions, traces, active);
if (!current) return null;
const archived = archivedAgentSessions(sessions, tabLimit);
const shown = archived.length ? sessions.filter((s) => !archived.includes(s)) : sessions;
const items: TabItem[] = [
...sessionItems(shown, runTargets),
...sessionItems(sessions, runTargets),
...traces.map((t) => ({ id: t.id, label: t.title, text: t.title, closable: true })),
];
const activeId = "session" in current ? current.session.id : current.trace.id;
return (
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Run sessions"
items={items}
activeId={activeId}
activeId={"session" in current ? current.session.id : current.trace.id}
onSelect={(id) => useRun.setState({ activeSession: id })}
onClose={(id) => void closeRunTab(id, traces)}
panelIdPrefix="openidea-run"
trailing={archived.length > 0 && <AgentArchive sessions={archived} activeId={activeId} />}
/>
);
}
/** The folder of archived agent tabs at the end of the strip, with their number. */
function AgentArchive({ sessions, activeId }: { sessions: RunSession[]; activeId: string }) {
const anchor = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const holdsActive = sessions.some((s) => s.id === activeId);
const count = sessions.length;
return (
<>
<button
ref={anchor}
type="button"
className={styles.agentArchive}
data-active={holdsActive || undefined}
aria-label={tx`Archived agent tabs: ${count}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<Icon name="folder" size={14} />
<span className={styles.agentArchiveCount}>{tx`Archived (${count})`}</span>
</button>
<Menu
open={open}
anchor={anchor}
onClose={() => setOpen(false)}
aria-label="Archived agent tabs"
items={[
...sessions.map((s) => ({
id: s.id,
label: s.config.name,
verbatim: true,
icon: sessionIcon(s),
checked: s.id === activeId,
run: () => useRun.setState({ activeSession: s.id }),
})),
{ kind: "separator" as const },
{ label: "Close All Archived Tabs", run: () => closeAgentArchive() },
]}
/>
</>
);
}
export function RunView() {
const sessions = useRun((s) => s.sessions);
const traces = useStackTraces((s) => s.tabs);
+2 -2
View File
@@ -1,4 +1,4 @@
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { call, commands, isTauri, type RunConfig, type TomcatInstallation, type WebArtifact } from "@/platform/ipc";
@@ -899,7 +899,7 @@ export function TomcatSettings({ config, onChange }: { config: RunConfig; onChan
iconSize={13}
title="Paste"
onClick={() => {
void readClipboardPlainText().then((text) => {
void navigator.clipboard?.readText().then((text) => {
if (!text.trim()) return;
const idx = text.indexOf("=");
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
-38
View File
@@ -1,38 +0,0 @@
/** Run configurations of commands agents run through the IDE's MCP server (`run_command`). */
import type { IpcPath, RunConfig } from "@/bindings";
import { workflowSettings } from "@/platform/workflowSettings";
/** Names the agent tab a run belongs to; the backend logs it for the agent. */
export const AGENT_PARAM = "mcp.agentRun";
/** Settings › Tools › Terminal › Shell path, which the backend runs the command line with. */
const SHELL_PARAM = "mcp.agentShell";
/** The agent tab name of a configuration, or `null` for any other run. */
export function agentName(config: Pick<RunConfig, "params">): string | null {
return config.params?.[AGENT_PARAM] ?? null;
}
/**
* The command line of agent tab `name`, with the shell of the IDE's terminal but on pipes (as
* JetBrains' `execute_terminal_command`): programs print plain lines, the console shows them and
* sends the user's input lines.
*/
export function agentConfig(name: string, command: string, cwd: IpcPath, env: Record<string, string>): RunConfig {
const terminal = workflowSettings().terminal;
return {
id: `mcp-agent:${name}`,
name,
kind: "shell",
command,
args: [],
cwd,
env: { ...terminal.env, ...env },
detected: false,
isTest: false,
debugAdapter: null,
allowParallel: true,
params: { [AGENT_PARAM]: name, [SHELL_PARAM]: terminal.shellPath },
// This machine: an agent's command never goes to a default run target.
target: "",
};
}
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { agentConfig, agentName } from "./agentConfig";
import { archivedAgentSessions } from "./agentRuns";
import type { RunSession } from "./store";
const plain: RunConfig = {
id: "npm:dev",
name: "dev",
kind: "npm",
command: "npm",
args: ["run", "dev"],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
};
function session(id: string, config: RunConfig, status: RunSession["status"], endedAt: number | null): RunSession {
return { id, config, runId: null, status, exitCode: 0, startedAt: 0, endedAt, stdinClosed: false };
}
describe("agent tabs", () => {
it("run the command line on pipes, on this machine", () => {
const config = agentConfig("_agent_build", "cargo build", "/project", { A: "1" });
expect(agentName(config)).toBe("_agent_build");
expect(agentName(plain)).toBeNull();
expect(config).toMatchObject({
id: "mcp-agent:_agent_build",
name: "_agent_build",
command: "cargo build",
args: [],
cwd: "/project",
target: "",
allowParallel: true,
});
expect(config.emulateTerminal).toBeFalsy();
expect(config.env).toMatchObject({ A: "1" });
expect(config.params).toHaveProperty("mcp.agentShell");
});
it("archive the oldest finished ones beyond the limit, never a running one or another run", () => {
const agent = (name: string) => agentConfig(name, "true", "/p", {});
const sessions = [
session("old", agent("_agent_old"), "finished", 1),
session("failed", agent("_agent_failed"), "failed", 2),
session("live", agent("_agent_live"), "running", null),
session("new", agent("_agent_new"), "finished", 3),
session("user", plain, "finished", 0),
];
expect(archivedAgentSessions(sessions, 1).map((s) => s.id)).toEqual(["failed", "old"]);
expect(archivedAgentSessions(sessions, 3)).toEqual([]);
expect(archivedAgentSessions(sessions, 0).map((s) => s.id)).toEqual(["new", "failed", "old"]);
});
});
-45
View File
@@ -1,45 +0,0 @@
/** The Run tool window's agent tabs: starting them, and archiving finished ones. */
import type { IpcPath } from "@/bindings";
import { agentRunSettings } from "@/platform/workflowSettings";
import { agentConfig, agentName } from "./agentConfig";
import { closeSession, isLive, startRun, useRun, type RunSession } from "./store";
export interface AgentRunRequest {
name: string;
command: string;
cwd: IpcPath;
env: Record<string, string>;
}
/** Starts an agent's command in its tab (reused once its last command finished). */
export async function startAgentRun(request: AgentRunRequest): Promise<void> {
const config = agentConfig(request.name, request.command, request.cwd, request.env);
const existing = useRun.getState().sessions.find((s) => s.config.id === config.id);
// The backend refuses a running name first; nothing here stops a run on the agent's behalf.
if (existing && isLive(existing)) throw new Error(`${request.name} is still running`);
const session = await startRun(config, existing?.id);
if (!session || session.status === "failed") throw new Error("The command did not start");
}
const endedAt = (s: RunSession) => s.endedAt ?? s.startedAt;
/** Finished agent tabs beyond the newest `limit`: the archive, newest first. Running ones never are. */
export function archivedAgentSessions(sessions: readonly RunSession[], limit: number): RunSession[] {
return sessions
.filter((s) => agentName(s.config) !== null && !isLive(s))
.sort((a, b) => endedAt(b) - endedAt(a))
.slice(Math.max(0, limit));
}
/** Closes the oldest archived agent tabs beyond the archive limit. */
export function trimAgentArchive() {
const { tabLimit, archiveLimit } = agentRunSettings();
const archived = archivedAgentSessions(useRun.getState().sessions, tabLimit);
for (const s of archived.slice(archiveLimit)) closeSession(s.id, { agentTabClosed: false });
}
/** Closes every archived agent tab. */
export function closeAgentArchive() {
const archived = archivedAgentSessions(useRun.getState().sessions, agentRunSettings().tabLimit);
for (const s of archived) closeSession(s.id);
}
+1 -17
View File
@@ -7,7 +7,7 @@ import { executeAction, executeActionOrThrow, getAction, registerActions } from
import { getContext, setContext, useContextKeys } from "@/platform/context";
import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
import {
menuGroupItems,
@@ -94,7 +94,6 @@ import {
isLive,
} from "./store";
import { rerunFailedConfigs } from "./testing/rerun";
import { startAgentRun, trimAgentArchive, type AgentRunRequest } from "./agentRuns";
import { compoundTargets, selectedTarget } from "./sessionTargets";
import { tx } from "@/platform/i18n";
@@ -637,16 +636,6 @@ export function activate(): () => void {
return loadConfigs();
},
},
{
id: "run.agentStart",
title: "Run Agent Command",
description:
"Runs `{ name, command, cwd, env }` from the MCP server's `run_command` in the agent tab `name`; throws when it did not start.",
category: "Run",
hidden: true,
when: "projectOpen",
run: ({ arg }) => startAgentRun(arg as AgentRunRequest),
},
{
id: "run.pluginStart",
title: "Run Plugin Configuration",
@@ -747,11 +736,6 @@ export function activate(): () => void {
setContext("run.cmakeBuild", cmakeBuildId(cmake, s.configs));
setContext("run.updatableRunning", updatableSessions().length > 0);
}),
// Agent tabs (`run_command`): the archive keeps at most Settings' number of them.
useRun.subscribe((s, prev) => {
if (s.sessions !== prev.sessions) trimAgentArchive();
}),
onWorkflowSettingsChange(() => agentRunSettings(), trimAgentArchive),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
resetRuns();
+1 -2
View File
@@ -16,7 +16,6 @@ import { findOutputLinks } from "./links";
import { useRun } from "./store";
import styles from "./StackTrace.module.css";
import { tx } from "@/platform/i18n";
import { readClipboardPlainText } from "@/platform/clipboard";
export interface StackTraceTab {
id: string;
@@ -84,7 +83,7 @@ export function closeStackTrace(id: string) {
async function openDialog() {
let clip = "";
try {
clip = await readClipboardPlainText();
clip = (await navigator.clipboard?.readText()) ?? "";
} catch {
// clipboard unavailable or denied: start empty
}
+1 -9
View File
@@ -58,7 +58,6 @@ import { compoundChildren } from "./configs";
import { remoteLabel, startWithRemedies } from "./remote";
import { TerminalOutput } from "./terminalOutput";
import { typeFor } from "./types";
import { agentName } from "./agentConfig";
import { loadRunTemplates } from "./runTemplates";
import { startedPort } from "@/platform/serverPort";
import { tomcatBrowserUrl } from "./tomcat";
@@ -1474,17 +1473,10 @@ export async function closeSessionTab(sessionId: string): Promise<boolean> {
return true;
}
/**
* Removes a session's tab (stopping its process). An agent tab's closing is told to the MCP
* server for the agent unless `agentTabClosed` is false (the archive dropping its oldest).
*/
export function closeSession(sessionId: string, options: { agentTabClosed?: boolean } = {}) {
export function closeSession(sessionId: string) {
const session = getSession(sessionId);
if (!session) return;
if (isLive(session)) void stopSession(sessionId);
const agent = agentName(session.config);
if (agent !== null && options.agentTabClosed !== false && isTauri())
void call(commands.mcpAgentRunClosed(agent)).catch(() => {});
const rt = runtimes.get(sessionId);
if (rt) cancelFlush(rt);
runtimes.delete(sessionId);
+3 -16
View File
@@ -18,13 +18,7 @@ import {
type ProjectStructureValid,
} from "@/platform/languageSettings";
import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings";
import {
DEFAULT_AGENT_RUNS,
DEFAULT_WORKFLOW,
validateAgentRuns,
validateWorkflow,
type WorkflowSettings,
} from "@/platform/workflowSettings";
import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings";
import { DEFAULT_INSPECTIONS, validateInspections, type InspectionSettingsValid } from "@/platform/inspections";
import { DEFAULT_PROXY, validateProxy, type ProxySettingsValid } from "@/platform/proxySettings";
import {
@@ -112,7 +106,7 @@ export interface ValidSettings extends WorkflowSettings {
/** Tools › Agents › Chat: the meters in an agent task's header. */
agents: Required<AgentSettings>;
/** Agents › IDE MCP Server (global). */
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean; agentTabLimit: number; agentArchiveLimit: number };
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean };
}
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
@@ -203,12 +197,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
defaults: {},
mcpServers: {},
},
mcpServer: {
enabled: false,
runWithoutConfirmation: false,
agentTabLimit: DEFAULT_AGENT_RUNS.tabLimit,
agentArchiveLimit: DEFAULT_AGENT_RUNS.archiveLimit,
},
mcpServer: { enabled: false, runWithoutConfirmation: false },
...DEFAULT_WORKFLOW,
};
@@ -381,8 +370,6 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
mcpServer: {
enabled: bool(obj(root.mcpServer).enabled, d.mcpServer.enabled),
runWithoutConfirmation: bool(obj(root.mcpServer).runWithoutConfirmation, d.mcpServer.runWithoutConfirmation),
agentTabLimit: validateAgentRuns(root.mcpServer).tabLimit,
agentArchiveLimit: validateAgentRuns(root.mcpServer).archiveLimit,
},
...validateWorkflow(root),
};
-17
View File
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { commands, call, isTauri, onIdeEvent, type McpServerStatus } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
import { useTx } from "@/platform/i18n";
import { Button, Input, T, copyText } from "@/ui";
import { Row } from "../components/Form";
@@ -135,22 +134,6 @@ export const MCP_SERVER_PAGE: PageSpec = {
label: "Run tools without confirmation (brave mode)",
hint: "Run configurations, terminal commands and other tools that change a project start without asking, for unattended agents. Only the IDE's own settings can turn this on, not a project's.",
},
{
kind: "number",
key: "mcpServer.agentTabLimit",
label: "Finished agent tabs to show",
...WORKFLOW_LIMITS.agentTabLimit,
integer: true,
hint: "Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.",
},
{
kind: "number",
key: "mcpServer.agentArchiveLimit",
label: "Archived agent tabs to keep",
...WORKFLOW_LIMITS.agentArchiveLimit,
integer: true,
hint: "Beyond this many archived agent tabs, the oldest are closed.",
},
{
kind: "custom",
label: "Endpoint",
+1 -1
View File
@@ -682,7 +682,7 @@ export const TOOLS_PAGES: PageSpec[] = [
key: "agents.newTaskApprovals",
label: "New tasks start",
options: NEW_TASK_APPROVAL_OPTIONS,
hint: "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
hint: "Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply",
},
],
},
+1 -2
View File
@@ -50,7 +50,6 @@ import { usePostfixSettings } from "./postfix/store";
import { showChooser, type ChooserItem } from "./popup/store";
import { expandEmmetAtCaret } from "./emmet/editor";
import { hasEmmet, loadEmmet } from "./emmet/model";
import { readClipboardPlainText } from "@/platform/clipboard";
export interface TemplateDocInfo {
path: string;
@@ -83,7 +82,7 @@ function userName(): string {
async function readClipboard(): Promise<string> {
try {
return await readClipboardPlainText();
return (await navigator.clipboard?.readText()) ?? "";
} catch {
return "";
}
-40
View File
@@ -142,27 +142,6 @@ describe("ownTerminalInput", () => {
expect(reached).toEqual(["keydown", "insertText"]);
});
it("sends a shifted character an IME commits before its keydown", () => {
// WeChat / Pinyin on macOS, Shift+/ for `?`: insertText arrives while only Shift is down.
// Its keydown armed xterm's gate, which then drops the first press.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "Shift", keyCode: 16 });
input(textarea, { inputType: "insertText", data: "?" });
keydown(textarea, { key: "?", keyCode: 229 });
keyup(textarea, 191);
expect(sent).toEqual(["?"]);
expect(reached).toEqual(["keydown"]);
});
it("leaves shifted keys xterm sent itself to xterm", () => {
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "Shift", keyCode: 16 });
keydown(textarea, { key: "A", keyCode: 65 });
input(textarea, { inputType: "insertText", data: "A" });
expect(sent).toEqual([]);
expect(reached).toEqual(["keydown", "keydown", "insertText"]);
});
it("leaves input with no keydown behind it to xterm", () => {
// Dictation, an on-screen keyboard or the emoji picker.
const { textarea, sent, reached } = setup();
@@ -280,25 +259,6 @@ describe("ownTerminalInput with xterm", () => {
return sent;
}
it("sends `?` on the first and the second press of Shift+/ with a direct-commit IME", async () => {
const sent = await replay([
["keydown", "Shift", 16],
["input", "insertText", "?", "?"],
["keydown", "?", 229],
["keyup", "?", 191],
["input", "insertText", "?", "??"],
["keydown", "?", 229],
["keyup", "?", 191],
["keyup", "Shift", 16],
["keydown", "Shift", 16],
["keydown", "A", 65],
["input", "insertText", "A", "??A"],
["keyup", "A", 65],
["keyup", "Shift", 16],
]);
expect(sent).toEqual(["?", "?", "A"]);
});
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
const sent = await replay([
+2 -16
View File
@@ -14,11 +14,7 @@
* - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run;
* - while such a keydown is pending, an `insertText` is sent from the event's own `data`
* (not from a value diff), then the textarea is cleared;
* - composition input that arrives without a `compositionstart` is sent the same way;
* - so is an `insertText` while only a modifier is down: an IME that commits a shifted
* character directly (`?` from Shift+/ with WeChat or Pinyin) delivers it before the
* character's keydown, and the Shift keydown already armed xterm's gate, so the first press
* was dropped.
* - composition input that arrives without a `compositionstart` is sent the same way.
*
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
* input events for keys xterm already sent, and input with no IME keydown behind it.
@@ -32,8 +28,6 @@
*/
import { isImeKey } from "@/platform/ime";
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
/** Commit-shaped input events that xterm's own input path ignores entirely. */
const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]);
@@ -68,9 +62,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
let imeKey = false;
/** The pending key belongs to an IME: its commit is ours to deliver. */
let imeDown = false;
/** Since the last keyup: a modifier went down, and any other key went down. */
let modifierDown = false;
let keyDown = false;
const onKeyDown = (e: KeyboardEvent) => {
if (composing) {
@@ -78,8 +69,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
if (e.keyCode === 0) e.stopPropagation();
return;
}
if (MODIFIERS.has(e.key)) modifierDown = true;
else keyDown = true;
imeKey = isImeKey(e);
imeDown = imeKey;
if (imeKey) {
@@ -101,8 +90,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
const onKeyUp = () => {
imeKey = false;
imeDown = false;
modifierDown = false;
keyDown = false;
};
const onInput = (e: InputEvent) => {
@@ -110,8 +97,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
if (e.inputType === "insertText") {
// Without an IME keydown behind it this is xterm's: it drops the event for a key it
// already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji).
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
if (!imeDown && !(modifierDown && !keyDown)) return;
if (!imeDown) return;
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
return;
}
-26
View File
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Terminal } from "@xterm/xterm";
import { commands } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
const notify = vi.hoisted(() => vi.fn());
@@ -526,28 +525,3 @@ it("recovers quoted local file paths when clipboard text permission is denied",
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
expect(notify).not.toHaveBeenCalled();
});
it.each([
// WebKitGTK pastes nothing from a menu through execCommand or readText: native text first.
{ os: "linux", native: "ls -la", expected: ["ls -la"], execCommand: 0 },
// An image-only clipboard keeps the WebView paths.
{ os: "linux", native: "", expected: [], execCommand: 1 },
{ os: "macos", native: "ls -la", expected: ["ls -la"], execCommand: 1 },
] as const)("menu paste on $os with native text '$native'", async ({ os, native, expected, execCommand }) => {
usePlatform.setState({ os });
vi.spyOn(commands, "clipboardText").mockResolvedValue({ status: "ok", data: native });
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "web" } });
const exec = vi.fn(() => false);
Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: () => true });
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
const paste = vi.fn();
try {
await pasteTerminalClipboard(paste, () => localTarget());
} finally {
usePlatform.setState({ os: "macos" });
Reflect.deleteProperty(document, "queryCommandSupported");
Reflect.deleteProperty(document, "execCommand");
}
expect(exec).toHaveBeenCalledTimes(execCommand);
expect(paste.mock.calls).toEqual(expected.map((text) => [text]));
});
+2 -12
View File
@@ -1,7 +1,6 @@
import { pasteClipboardFiles, readClipboardPlainText, readClipboardText } from "@/platform/clipboard";
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
import { call, commands, type TerminalId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { usePlatform } from "@/platform/platformInfo";
import { notifications } from "@/workbench/contributions";
export interface TerminalPasteTarget {
@@ -121,15 +120,6 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) {
const target = getTarget?.();
try {
// WebKitGTK pastes nothing from a menu through either WebView path; the native
// clipboard has the text. An image-only clipboard still takes the paths below.
if (target && usePlatform.getState().os === "linux") {
const text = await readClipboardPlainText().catch(() => "");
if (text) {
if (target.current()) paste(text);
return;
}
}
// Native paste preserves clipboard blobs where Clipboard.read() is unavailable.
if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return;
const images =
@@ -140,7 +130,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
)
: null;
const text = target?.remote
? await readClipboardPlainText().catch(() => "")
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
if (target && !target.current()) return;
if (text) {
+1 -9
View File
@@ -25,7 +25,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import type { Branch, ChangedFile, Commit } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { maybeEditorService, useGitLogChecks, type EditorInput } from "@/workbench/contributions";
import {
EmptyState,
@@ -2628,7 +2627,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
},
[scope],
);
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
if (!commit)
return (
@@ -2669,13 +2667,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
const details = (
<SplitGroup orientation="vertical" id="git-log-details">
<SplitPanel id="files" defaultSize="60%" minSize="60px">
<div
className={styles.filesPane}
onFocusCapture={() => setContext("vcsLogChangesFocus", true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("vcsLogChangesFocus", false);
}}
>
<div className={styles.filesPane}>
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
<IconButton
icon="diff"
+1 -2
View File
@@ -62,7 +62,6 @@ import { refreshNow } from "./store";
import styles from "./MergeEditor.module.css";
import type { MergeData } from "./mergeKind";
import { readClipboardPlainText } from "@/platform/clipboard";
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
const NOTHING_LEFT: MergeSummary = {
@@ -498,7 +497,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
const { from, to } = view.state.selection.main;
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
else if (id === "paste") {
const text = await readClipboardPlainText().catch(() => undefined);
const text = await navigator.clipboard?.readText().catch(() => undefined);
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
} else {
await copyText(view.state.sliceDoc(from, to));
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { fileWebUrl, remoteHosting } from "../model";
describe("remoteHosting", () => {
it.each([
["[email protected]:o/r.git", "GitHub", "github"],
["https://github.example.com/o/r", "GitHub", "github"],
["https://gitee.com/o/r.git", "Gitee", "github"],
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
["[email protected]:group/r.git", "git.example.com", "gitlab"],
])("%s → %s", (url, name, kind) => {
expect(remoteHosting(url)).toEqual({ name, kind });
});
it("has none for a local remote", () => {
expect(remoteHosting("/srv/git/r.git")).toBeNull();
});
});
describe("fileWebUrl", () => {
it("opens a file at a line on GitHub", () => {
expect(fileWebUrl("[email protected]:o/r.git", "main", "src/a b.ts", { line: 9 })).toBe(
"https://github.com/o/r/blob/main/src/a%20b.ts#L10",
);
});
it("keeps a branch's slashes and opens directories as trees", () => {
expect(fileWebUrl("[email protected]:o/r.git", "feature/x", "src", { dir: true, line: 3 })).toBe(
"https://github.com/o/r/tree/feature/x/src",
);
});
it("uses GitLab's /-/ routes, including subgroups", () => {
expect(fileWebUrl("ssh://[email protected]:2222/group/sub/r.git", "abc123", "docs/中文.md", { line: 0 })).toBe(
"https://gitlab.example.com/group/sub/r/-/blob/abc123/docs/%E4%B8%AD%E6%96%87.md#L1",
);
expect(fileWebUrl("[email protected]:g/r.git", "main", "", { dir: true })).toBe("https://gitlab.com/g/r/-/tree/main");
});
it("uses Bitbucket's /src/ routes", () => {
expect(fileWebUrl("[email protected]:o/r.git", "main", "a.py", { line: 4 })).toBe(
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
);
});
});
+1 -37
View File
@@ -12,8 +12,7 @@ vi.mock("@/platform/ipc", async (original) => {
},
};
});
import { act, renderHook } from "@testing-library/react";
import { loadLogDetails, useCommitDetails } from "../logDetails";
import { loadLogDetails } from "../logDetails";
import { useVcs } from "../store";
import { useGitLog } from "../logStore";
const commit = (id: string): Commit => ({
@@ -93,38 +92,3 @@ describe("commit detail jobs", () => {
await Promise.all([first, second]);
});
});
describe("useCommitDetails", () => {
it("keeps the shown details while a repository refresh reloads them", async () => {
const c = commit("kept-on-refresh");
const { result, rerender } = renderHook(({ c }) => useCommitDetails(c), { initialProps: { c } });
await vi.waitFor(() => expect(result.current.details?.message).toBe("kept-on-refresh"));
let finish!: () => void;
api.batch.mockImplementationOnce(
(_repo: string, ids: string[]) =>
new Promise((resolve) => {
finish = () =>
resolve({
status: "ok",
data: ids.map(() => ({
message: "reloaded",
containingBranches: ["main"],
signature: "N",
signer: "",
key: "",
fingerprint: "",
})),
});
}),
);
act(() => useVcs.setState((s) => ({ version: s.version + 1 })));
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2));
expect(result.current.details?.message).toBe("kept-on-refresh");
await act(async () => finish());
await vi.waitFor(() => expect(result.current.details?.message).toBe("reloaded"));
// Another commit shows nothing until its own details arrive.
api.batch.mockImplementationOnce(() => new Promise(() => {}));
rerender({ c: commit("another") });
expect(result.current.details).toBeNull();
});
});
+1 -2
View File
@@ -373,8 +373,7 @@ export function registerVcsActions(): () => void {
icon: "diff",
when: VCS,
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
// In the Git Log's changed files it shows the commit's diff instead.
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
keyWhen: "!editorTextFocus",
run: async ({ arg, ctx }) => {
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
const line =
+1 -2
View File
@@ -14,7 +14,6 @@ export { compareFilesData, displayPath } from "./compareData";
import { openDiff, openVcsDiff, type DiffData } from "./diffInput";
import { baseName } from "./model";
import { tx } from "@/platform/i18n";
import { readClipboardPlainText } from "@/platform/clipboard";
function argPath(arg: unknown): string | undefined {
if (typeof arg === "string") return arg;
@@ -286,7 +285,7 @@ export function registerCompareActions(): () => void {
if (!path) return info("Open or select a file to compare with the clipboard");
let clipboard: string;
try {
clipboard = await readClipboardPlainText();
clipboard = (await navigator.clipboard?.readText()) ?? "";
} catch {
return info("The clipboard could not be read");
}
+1 -2
View File
@@ -109,8 +109,7 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
<div
ref={body}
className="vcsDiffDialogBody"
// The dialog body is not a flex container: fill it so the editors reach the footer.
style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
>
{children}
</div>
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more