mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de12bf1189 | ||
|
|
4c71891924 | ||
|
|
6143011880 | ||
|
|
d1872c07ce | ||
|
|
cd79293fd7 | ||
|
|
86f7b747a5 | ||
|
|
2e5e9acd9f | ||
|
|
b5e9e27dad | ||
|
|
ff1bfeea9d | ||
|
|
14ee1a97f2 | ||
|
|
916c4243bf | ||
|
|
9c63f174e4 | ||
|
|
b463252bc5 | ||
|
|
2abdac4fdb | ||
|
|
721f4144c1 | ||
|
|
55057b8958 | ||
|
|
8abc8e7fbf | ||
|
|
68dd949df3 | ||
|
|
b98bddbbab | ||
|
|
21b05ab73a | ||
|
|
7d7de0b966 | ||
|
|
830f4aa802 | ||
|
|
c905f1428e | ||
|
|
a8036ce6fa | ||
|
|
28829ad756 | ||
|
|
d6c562049f | ||
|
|
100075166c | ||
|
|
d5332a2197 | ||
|
|
b0d20d7997 | ||
|
|
8df59cf5a7 | ||
|
|
3c68d2ea7f | ||
|
|
cfe1416dbd | ||
|
|
44fc0a2ebc |
No files matched your search
@@ -151,6 +151,13 @@ jobs:
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||
pnpm exec tauri signer sign --app-version $version $asset
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
gh release upload $env:TAG "$asset.sig" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
@@ -233,6 +240,8 @@ jobs:
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
|
||||
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
|
||||
+140
-10
@@ -1680,6 +1680,15 @@ 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`.
|
||||
@@ -1759,6 +1768,20 @@ 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)
|
||||
@@ -1769,15 +1792,56 @@ pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<Pa
|
||||
// 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);
|
||||
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
|
||||
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
return None; // Management entries are declarations; there is nothing to go to.
|
||||
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 kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
managed_target(text, path, e, kind, local_repo)
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
|
||||
@@ -3087,9 +3151,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: it is the declaration.
|
||||
// The dependency names its own version and no local repository has its POM.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
|
||||
// A management entry is a declaration itself, not a reference.
|
||||
// A management entry declares the version; its artifact's POM is not at hand either.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
assert_eq!(
|
||||
@@ -3101,6 +3165,72 @@ 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!(
|
||||
|
||||
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -373,6 +380,14 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -8,13 +8,18 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
/// The `encoding_rs` encoding of a Windows code page, when it has one.
|
||||
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
|
||||
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
|
||||
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
|
||||
pub fn console_code_page() -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
|
||||
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
|
||||
(cp != 65001).then_some(cp)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
|
||||
/// piece by piece.
|
||||
#[cfg(windows)]
|
||||
pub fn is_single_byte_code_page(cp: u32) -> bool {
|
||||
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
|
||||
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
|
||||
unsafe {
|
||||
let mut info: CPINFO = std::mem::zeroed();
|
||||
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
|
||||
/// like the OEM pages 437, 850 or 852).
|
||||
#[cfg(windows)]
|
||||
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
|
||||
use windows_sys::Win32::Globalization::MultiByteToWideChar;
|
||||
let Ok(len) = i32::try_from(bytes.len()) else {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
};
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
|
||||
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
|
||||
if needed <= 0 {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut wide = vec![0u16; needed as usize];
|
||||
// SAFETY: `wide` holds `needed` units.
|
||||
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
|
||||
String::from_utf16_lossy(&wide[..written.max(0) as usize])
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
@@ -296,6 +296,16 @@ pub struct McpServerSettings {
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
}
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
|
||||
@@ -143,9 +143,12 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
|
||||
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
|
||||
std::fs::write(root.join("main.py"), "").unwrap();
|
||||
std::thread::sleep(SETTLE * 3);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(count.load(Ordering::SeqCst), seen);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.6.0";
|
||||
pub const ENGINE_VERSION: &str = "1.7.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Host {
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
@@ -141,6 +143,7 @@ impl Host {
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
@@ -269,6 +272,7 @@ impl Host {
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.mcp_agent_runs.forget_window(window);
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
|
||||
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
|
||||
|
||||
use super::editors::problem;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult, Severity};
|
||||
use ide_lsp::DocumentCheck;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 30_000;
|
||||
const MAX_FILES: usize = 20;
|
||||
|
||||
pub fn check_files() -> Tool {
|
||||
Tool::new(
|
||||
"check_files",
|
||||
&format!(
|
||||
"Checks files with the language servers now and returns their problems: call it after changing files \
|
||||
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
|
||||
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
|
||||
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
|
||||
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
|
||||
|
||||
Per file, `status` says what the problems are worth:
|
||||
- checked: every server answered; `problems` is complete (empty means no problems).
|
||||
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
|
||||
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
|
||||
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
|
||||
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
|
||||
- noLanguageServer: no language server is configured for this kind of file.
|
||||
- untrusted: language servers don't run in an untrusted project.
|
||||
- failed: the file could not be opened (`error`).
|
||||
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
|
||||
the file on disk. Lines and UTF-16 columns are 1-based."
|
||||
),
|
||||
|call| async move {
|
||||
let raw = call.str_list("filePaths")?;
|
||||
if raw.is_empty() {
|
||||
return Err(IdeError::invalid("filePaths must name at least one file"));
|
||||
}
|
||||
if raw.len() > MAX_FILES {
|
||||
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
|
||||
}
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let mut paths = Vec::new();
|
||||
for p in raw {
|
||||
paths.push(call.resolve_readable(&p).await?);
|
||||
}
|
||||
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
|
||||
let files = futures::future::join_all(checks).await;
|
||||
Output::json(json!({ "files": files }))
|
||||
},
|
||||
)
|
||||
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
|
||||
.param(Param::optional(
|
||||
"errorsOnly",
|
||||
ParamType::Boolean,
|
||||
"Whether to return only errors (default) or warnings and hints too",
|
||||
))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
|
||||
}
|
||||
|
||||
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
|
||||
let mut out = json!({ "filePath": call.display(&path) });
|
||||
if !call.project().is_trusted() {
|
||||
out["status"] = "untrusted".into();
|
||||
return out;
|
||||
}
|
||||
match check(call, &path, limit).await {
|
||||
Ok((check, dirty, text)) => {
|
||||
let servers: Vec<Value> = check
|
||||
.servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
|
||||
if let Some(m) = &s.message {
|
||||
v["message"] = m.clone().into();
|
||||
}
|
||||
if s.incomplete {
|
||||
v["incomplete"] = true.into();
|
||||
}
|
||||
if let Some(e) = &s.error {
|
||||
v["error"] = e.clone().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
out["status"] = file_status(&check).into();
|
||||
out["problems"] = check
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.filter(|d| !errors_only || d.severity == Severity::Error)
|
||||
.map(|d| problem(d, &text))
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
out["servers"] = servers.into();
|
||||
if dirty {
|
||||
out["unsavedInEditor"] = true.into();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
out["status"] = "failed".into();
|
||||
out["error"] = e.message.into();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn file_status(check: &DocumentCheck) -> &'static str {
|
||||
if check.servers.is_empty() {
|
||||
"noLanguageServer"
|
||||
} else if !check.servers.iter().any(|s| s.checked) {
|
||||
"serverUnavailable"
|
||||
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
|
||||
"checked"
|
||||
} else {
|
||||
"incomplete"
|
||||
}
|
||||
}
|
||||
|
||||
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
|
||||
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
|
||||
let project = call.project().clone();
|
||||
let docs = project.documents.clone();
|
||||
let p = path.to_owned();
|
||||
let at = Instant::now();
|
||||
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
|
||||
let (doc, changed, dirty) = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
|
||||
docs.on_disk_changed(std::slice::from_ref(&p));
|
||||
let doc = docs.open(&p)?.id;
|
||||
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
|
||||
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
|
||||
})
|
||||
.await?;
|
||||
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
|
||||
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
|
||||
let docs = project.documents.clone();
|
||||
let _ = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
docs.close(doc);
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
Ok((result?, dirty, text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_lsp::CheckedServer;
|
||||
|
||||
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
|
||||
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_say_whether_the_problems_are_complete() {
|
||||
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
|
||||
assert_eq!(check(vec![]), "noLanguageServer");
|
||||
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
|
||||
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
|
||||
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
|
||||
assert_eq!(
|
||||
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
|
||||
"incomplete"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
|
||||
})
|
||||
}
|
||||
|
||||
fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
json!({
|
||||
"severity": d.severity, "description": d.message,
|
||||
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
|
||||
|
||||
@@ -30,9 +30,14 @@ fn read_file() -> Tool {
|
||||
"Reads a file in the project directory or from any project dependency or other project source root.
|
||||
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
|
||||
Returns numbered lines (1-indexed) as text.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
|
||||
lines with a `…N lines truncated…` marker between them.
|
||||
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
|
||||
followed by `…N more lines; read on with offset=M…` when the file goes on.
|
||||
The maximum accepted limit is 5000 lines.",
|
||||
|call| async move {
|
||||
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
|
||||
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
|
||||
let offset = positive(&call, "offset", 1)?;
|
||||
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
|
||||
if limit > MAX_READ_LIMIT {
|
||||
@@ -50,7 +55,7 @@ The maximum accepted limit is 5000 lines.",
|
||||
)));
|
||||
}
|
||||
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
|
||||
},
|
||||
)
|
||||
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
|
||||
@@ -70,10 +75,12 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
|
||||
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
|
||||
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
|
||||
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
|
||||
/// marker between them.
|
||||
/// Lines are the editor's: a trailing line break starts an empty last line.
|
||||
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
|
||||
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
let count = lines.len();
|
||||
if start > count {
|
||||
@@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String>
|
||||
if available <= max_lines {
|
||||
return Ok(range(start, count));
|
||||
}
|
||||
if window {
|
||||
let end = start + max_lines - 1;
|
||||
return Ok(format!(
|
||||
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
|
||||
range(start, end),
|
||||
count - end,
|
||||
end + 1
|
||||
));
|
||||
}
|
||||
let head = max_lines / 2;
|
||||
let tail = max_lines - head;
|
||||
let head_end = start + head - 1;
|
||||
@@ -245,7 +261,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slice(text: &str, start: usize, max: usize) -> String {
|
||||
render_slice(text, start, max).unwrap()
|
||||
render_slice(text, start, max, false).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -254,18 +270,27 @@ mod tests {
|
||||
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
|
||||
assert_eq!(slice("", 1, 10), "L1: ");
|
||||
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
|
||||
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_head_and_tail_of_long_slices() {
|
||||
fn keeps_head_and_tail_of_a_long_default_read() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
let window = |start, max| render_slice(&text, start, max, true).unwrap();
|
||||
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
|
||||
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
|
||||
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_long_lines_at_utf16_units() {
|
||||
let long = "x".repeat(2100);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
|
||||
mod check;
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
mod run;
|
||||
pub(crate) mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
@@ -16,6 +17,7 @@ use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,6 +32,7 @@ fn tools() -> Vec<tool::Tool> {
|
||||
let mut tools = files::tools();
|
||||
tools.extend(search::tools());
|
||||
tools.extend(editors::tools());
|
||||
tools.push(check::check_files());
|
||||
tools.push(symbols::get_symbol_info());
|
||||
tools.push(symbols::find_references());
|
||||
tools.extend(edit::tools());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,418 @@
|
||||
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
|
||||
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUN_TIMEOUT_MS: usize = 10_000;
|
||||
const WAIT_TIMEOUT_MS: usize = 30_000;
|
||||
const STOP_TIMEOUT_MS: usize = 10_000;
|
||||
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
|
||||
const DEFAULT_MAX_LINES: usize = 2000;
|
||||
/// Bytes of the log read for one result at most.
|
||||
const READ_CAP: usize = 1 << 20;
|
||||
/// Lines of output quoted when a name is still running.
|
||||
const BUSY_TAIL_LINES: usize = 20;
|
||||
|
||||
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
|
||||
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
|
||||
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
|
||||
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
|
||||
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
|
||||
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
|
||||
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
|
||||
wait for their confirmation before you continue.";
|
||||
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
|
||||
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
|
||||
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
|
||||
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
|
||||
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
call.host()
|
||||
.mcp_agent_runs
|
||||
.get(call.root(), &name)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
|
||||
}
|
||||
|
||||
/// `status` of `run`, with interventions not told yet.
|
||||
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
|
||||
let mut out = run.status();
|
||||
out.extend(extra);
|
||||
let interventions = run.take_interventions();
|
||||
if !interventions.is_empty() {
|
||||
out.insert("interventions".into(), interventions.into());
|
||||
out.insert("advice".into(), INTERVENTION_ADVICE.into());
|
||||
}
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
|
||||
/// as `truncateMode` says.
|
||||
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
|
||||
let max_lines = match call.opt_i64("maxLinesCount")? {
|
||||
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
|
||||
Some(n) => n as usize,
|
||||
None => DEFAULT_MAX_LINES,
|
||||
};
|
||||
let mode = match call.opt_str("truncateMode")? {
|
||||
Some(name) => TruncateMode::parse(&name)
|
||||
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
|
||||
None => TruncateMode::Start,
|
||||
};
|
||||
let len = run.log_len();
|
||||
let from = from.min(len);
|
||||
// A long output is read from the end that stays.
|
||||
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
|
||||
let (raw, start, end) = run.read(start, READ_CAP)?;
|
||||
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
|
||||
let mut out = Map::new();
|
||||
out.insert("output".into(), text.into());
|
||||
if truncated > 0 {
|
||||
out.insert("truncatedLines".into(), truncated.into());
|
||||
}
|
||||
out.insert("outputStart".into(), start.into());
|
||||
out.insert("nextOffset".into(), end.into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
|
||||
Ok(tokio::time::Instant::now() + timeout(call, default)?)
|
||||
}
|
||||
|
||||
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
|
||||
call.opt_str("pattern")?
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
|
||||
match call.opt_i64(name)? {
|
||||
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
|
||||
n => Ok(n.map(|n| n as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
fn busy(run: &AgentRun) -> IdeError {
|
||||
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
|
||||
IdeError::invalid(format!(
|
||||
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
|
||||
run.name,
|
||||
run.command(),
|
||||
clean_output(&tail).trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_command() -> Tool {
|
||||
Tool::new(
|
||||
"run_command",
|
||||
&format!(
|
||||
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
|
||||
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
|
||||
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
|
||||
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
|
||||
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
|
||||
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
|
||||
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
let command = call.str("command")?;
|
||||
if command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("command must not be empty"));
|
||||
}
|
||||
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
|
||||
Some(dir) => {
|
||||
let path = call.resolve_inside(&dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
|
||||
}
|
||||
path
|
||||
}
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
let runs = &host.mcp_agent_runs;
|
||||
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
|
||||
return Err(busy(&run));
|
||||
}
|
||||
let shown_cwd = call.display(&cwd);
|
||||
let mut detail = format!(
|
||||
"{command}\n\nTab: {name}\nWorking directory: {}",
|
||||
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
|
||||
);
|
||||
for (key, value) in env.iter().flatten() {
|
||||
detail.push_str(&format!("\n{key}={value}"));
|
||||
}
|
||||
call.confirm(&name, Some(detail)).await?;
|
||||
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
|
||||
let request = ide_core::event::McpUiRequest::AgentRun {
|
||||
name: name.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.into(),
|
||||
env: env.unwrap_or_default().into_iter().collect(),
|
||||
};
|
||||
if let Err(e) = call.ask(request).await {
|
||||
let error = run.start_error();
|
||||
runs.abandon(&run);
|
||||
return Err(match error {
|
||||
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(error) = run.start_error() {
|
||||
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
|
||||
}
|
||||
let from = run.process_offset();
|
||||
let end = run.wait(deadline, None, from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::required(
|
||||
"command",
|
||||
ParamType::String,
|
||||
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Directory inside the project to run in, relative to the project root; default: the project root",
|
||||
))
|
||||
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
|
||||
.param(Param::optional(
|
||||
"timeout",
|
||||
ParamType::Integer,
|
||||
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
|
||||
keeps running after it",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn wait_for_run() -> Tool {
|
||||
Tool::new(
|
||||
"wait_for_run",
|
||||
&format!(
|
||||
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
|
||||
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
|
||||
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
|
||||
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
|
||||
let pattern = pattern(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
|
||||
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
|
||||
.param(Param::optional(
|
||||
"pattern",
|
||||
ParamType::String,
|
||||
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"offset",
|
||||
ParamType::Integer,
|
||||
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
|
||||
command's output starts",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn read_run_output() -> Tool {
|
||||
Tool::new(
|
||||
"read_run_output",
|
||||
&format!(
|
||||
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
|
||||
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
|
||||
{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or(0);
|
||||
let extra = output_view(&call, &run, from)?;
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn get_run_status() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_status",
|
||||
&format!(
|
||||
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
|
||||
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
|
||||
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
|
||||
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
}
|
||||
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Key {
|
||||
/// Writes this text to its input.
|
||||
Text(&'static str),
|
||||
/// Ctrl+C: stops it, as `stop_run` does.
|
||||
Interrupt,
|
||||
/// Ctrl+D: ends its input.
|
||||
EndOfInput,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> IdeResult<Key> {
|
||||
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
|
||||
"enter" | "return" => Key::Text("\n"),
|
||||
"tab" => Key::Text("\t"),
|
||||
"ctrl+c" => Key::Interrupt,
|
||||
"ctrl+d" => Key::EndOfInput,
|
||||
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
|
||||
Ctrl+C to stop it or Ctrl+D to end its input"
|
||||
)));
|
||||
}
|
||||
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_run_input() -> Tool {
|
||||
Tool::new(
|
||||
"send_run_input",
|
||||
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
|
||||
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
|
||||
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
|
||||
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let text = call.opt_str("text")?.unwrap_or_default();
|
||||
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
|
||||
if text.is_empty() && keys.is_empty() {
|
||||
return Err(IdeError::invalid("pass `text`, `keys` or both"));
|
||||
}
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
call.project().require_trust()?;
|
||||
let host = call.host();
|
||||
let mut data = text;
|
||||
for key in keys {
|
||||
match key {
|
||||
Key::Text(text) => data.push_str(text),
|
||||
Key::EndOfInput | Key::Interrupt => {
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
|
||||
}
|
||||
if key == Key::EndOfInput {
|
||||
crate::ops::run::close_stdin(&host, id)?;
|
||||
} else {
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &data)?;
|
||||
}
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("text", ParamType::String, "Text to type"))
|
||||
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
|
||||
}
|
||||
|
||||
pub fn stop_run() -> Tool {
|
||||
Tool::new(
|
||||
"stop_run",
|
||||
&format!(
|
||||
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
|
||||
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
|
||||
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
|
||||
keeps `stopping`: call again with `force`. The tab stays open with its output."
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
let force = call.flag("force", false)?;
|
||||
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
if force {
|
||||
// A second Stop kills the tree.
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
}
|
||||
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
|
||||
let mut extra = Map::new();
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
|
||||
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
|
||||
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
|
||||
assert!(key("F13").unwrap_err().message.contains("unknown key"));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 60_000;
|
||||
const MAX_TIMEOUT_MS: usize = 3_600_000;
|
||||
const MAX_PROBLEMS: usize = 300;
|
||||
/// Console lines attached to a failure whose messages were not parsed.
|
||||
const CONSOLE_LINES: usize = 200;
|
||||
@@ -84,13 +83,13 @@ You have to use this tool after performing edits to validate if the edits are va
|
||||
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
|
||||
modules containing those files. Asks the user first for every call. Problems have the project-relative \
|
||||
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
|
||||
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|
||||
is an ERROR. On timeout (0 times out at once) the build is stopped.",
|
||||
|call| async move {
|
||||
let files: Vec<String> =
|
||||
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
|
||||
let rebuild = call.flag("rebuild", false)? && files.is_empty();
|
||||
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let mut targets: Vec<Option<PathBuf>> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for f in &files {
|
||||
|
||||
@@ -19,8 +19,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// No limit in JetBrains; the process keeps running after the timeout anyway.
|
||||
const MAX_TIMEOUT_MS: usize = 1_800_000;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
@@ -541,7 +539,7 @@ tool window; output files are private and deleted after a day.",
|
||||
)?;
|
||||
let wait = call.flag("waitForExit", true)?;
|
||||
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
|
||||
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let configs = load_configs(&call).await?;
|
||||
let mut config = match &target {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
//! Run configurations, builds and agent commands; every start asks the user first.
|
||||
|
||||
pub(crate) mod agent;
|
||||
mod agent_tools;
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
@@ -11,15 +13,22 @@ use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
let mut tools =
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
|
||||
tools.extend(agent_tools::tools());
|
||||
tools
|
||||
}
|
||||
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
|
||||
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
|
||||
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
|
||||
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
|
||||
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
|
||||
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
|
||||
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
|
||||
Ok(match call.opt_i64("timeout")? {
|
||||
None => Duration::from_millis(default as u64),
|
||||
Some(n) if n <= 0 => Duration::ZERO,
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
|
||||
})
|
||||
}
|
||||
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
|
||||
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
|
||||
/// runs from the workspace root), else its working directory.
|
||||
fn scope_of(root: &Path, c: &RunConfig) -> String {
|
||||
match c.extras.module.as_deref() {
|
||||
Some(module) if c.kind == "cargo" => {
|
||||
let module = module.replace('\\', "/");
|
||||
let module = module.strip_prefix("./").unwrap_or(&module);
|
||||
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
_ => cwd_of(root, c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
|
||||
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
|
||||
let score = |c: &RunConfig| -> Option<usize> {
|
||||
let cwd = cwd_of(root, c);
|
||||
let cwd = scope_of(root, c);
|
||||
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
|
||||
};
|
||||
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
|
||||
@@ -922,6 +935,33 @@ mod tests {
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_member_runs_its_own_tests() {
|
||||
let root = Path::new("/p");
|
||||
let member = |pkg: &str, dir: &str| {
|
||||
let mut c = blank(
|
||||
format!("cargo:test:{pkg}"),
|
||||
"test",
|
||||
"cargo",
|
||||
"cargo".into(),
|
||||
vec!["test".into(), "-p".into(), pkg.into()],
|
||||
);
|
||||
c.is_test = true;
|
||||
c.extras.module = Some(dir.into());
|
||||
c
|
||||
};
|
||||
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
|
||||
workspace.is_test = true;
|
||||
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
|
||||
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
|
||||
let rel = "crates/ide-mcp/src/stdio.rs";
|
||||
let ts = detect(src, rel);
|
||||
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
|
||||
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_args_get_one_separator() {
|
||||
let root = Path::new("/p");
|
||||
|
||||
@@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500;
|
||||
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
|
||||
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
|
||||
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
|
||||
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
|
||||
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
|
||||
each side. At most 50. Default: the matched line only.";
|
||||
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
|
||||
/// on each side of it; a longer context line to its start.
|
||||
const MAX_LINE_TEXT: usize = 300;
|
||||
const SNIPPET_AROUND: usize = 100;
|
||||
const MAX_CONTEXT_LINES: u32 = 50;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![search_text(), search_regex(), search_symbol(), search_file()]
|
||||
@@ -49,6 +57,8 @@ fn search_text() -> Tool {
|
||||
"Searches for a text substring within project files.
|
||||
Use this tool for fast text search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
@@ -57,6 +67,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Text to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -66,6 +77,8 @@ fn search_regex() -> Tool {
|
||||
"Searches for regex matches within project files.
|
||||
Use this tool when you need regex search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
@@ -75,6 +88,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -84,6 +98,7 @@ fn search_symbol() -> Tool {
|
||||
"Searches for symbols (classes, methods, fields).
|
||||
Use this tool for semantic lookup by identifier fragments.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Project results have their line (`lineText`), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
By default this searches project symbols only.
|
||||
@@ -99,6 +114,7 @@ If you don't find a suitable result, try again with include_external=true to sea
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -145,6 +161,13 @@ struct SearchItem {
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
/// The text of `start_line` (cut when long).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
line_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_before: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
@@ -182,7 +205,9 @@ fn result_schema() -> Value {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
|
||||
"name": string, "container": string,
|
||||
"name": string, "container": string, "lineText": string,
|
||||
"contextBefore": { "type": "array", "items": string },
|
||||
"contextAfter": { "type": "array", "items": string },
|
||||
},
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
@@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
// --- matched lines -------------------------------------------------------------
|
||||
|
||||
/// The `context` argument as (lines before, lines after).
|
||||
fn context(call: &Call) -> IdeResult<(u32, u32)> {
|
||||
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
|
||||
let (sign, n) = match call.args.get("context") {
|
||||
None | Some(Value::Null) => return Ok((0, 0)),
|
||||
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
|
||||
Some(Value::String(s)) => {
|
||||
let s = s.trim();
|
||||
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
|
||||
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
|
||||
None => (None, s.parse().map_err(|_| invalid())?),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(invalid()),
|
||||
};
|
||||
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
|
||||
Ok(match sign {
|
||||
Some('+') => (0, n),
|
||||
Some(_) => (n, 0),
|
||||
None => (n, n),
|
||||
})
|
||||
}
|
||||
|
||||
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
|
||||
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
|
||||
fn snippet(line: &str, start: usize, end: usize) -> String {
|
||||
let units: usize = line.chars().map(char::len_utf16).sum();
|
||||
if units <= MAX_LINE_TEXT {
|
||||
return line.to_owned();
|
||||
}
|
||||
let from = start.saturating_sub(SNIPPET_AROUND);
|
||||
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
|
||||
let (mut out, mut at) = (String::new(), 0);
|
||||
for c in line.chars() {
|
||||
if at >= from && at < to {
|
||||
out.push(c);
|
||||
}
|
||||
at += c.len_utf16();
|
||||
}
|
||||
let before = if from > 0 { "\u{2026}" } else { "" };
|
||||
let after = if to < units { "\u{2026}" } else { "" };
|
||||
format!("{before}{out}{after}")
|
||||
}
|
||||
|
||||
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
|
||||
fn context_line(line: &str) -> String {
|
||||
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
|
||||
}
|
||||
|
||||
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
|
||||
/// as they are); each file is read once, with unsaved editor text.
|
||||
async fn fill_lines(
|
||||
call: &Call,
|
||||
items: &mut [SearchItem],
|
||||
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
|
||||
(before, after): (u32, u32),
|
||||
) {
|
||||
let host = call.host();
|
||||
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
|
||||
for item in items {
|
||||
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
|
||||
if current.as_ref().is_none_or(|(p, _)| p != &path) {
|
||||
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
|
||||
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
|
||||
current = Some((path, lines));
|
||||
}
|
||||
let Some((_, Some(lines))) = ¤t else { continue };
|
||||
let index = line as usize - 1;
|
||||
let Some(text) = lines.get(index) else { continue };
|
||||
let start = item.start_column.unwrap_or(1) as usize - 1;
|
||||
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
|
||||
item.line_text = Some(snippet(text, start, end));
|
||||
let first = index.saturating_sub(before as usize);
|
||||
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
|
||||
let last = (index + after as usize).min(lines.len() - 1);
|
||||
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// --- path globs ----------------------------------------------------------------
|
||||
|
||||
/// The `paths` argument: project-relative globs, `!` excludes.
|
||||
@@ -416,6 +522,7 @@ struct TextHit {
|
||||
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
// Globs Find in Files cannot take are applied afterwards, so the engine must not
|
||||
// stop at `limit` matches the filter may drop.
|
||||
@@ -494,7 +601,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let more = hits.len() > limit || summary.limit_hit || timed_out;
|
||||
hits.truncate(limit);
|
||||
extend_spanning(&call, &query, &mut hits, spanning).await;
|
||||
let items = hits
|
||||
let mut items: Vec<SearchItem> = hits
|
||||
.into_iter()
|
||||
.map(|h| SearchItem {
|
||||
file_path: h.file_path,
|
||||
@@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let absolute: std::collections::HashMap<&str, &Path> =
|
||||
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
|
||||
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
@@ -726,6 +836,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
let started = Instant::now();
|
||||
let q = query(&call, "Search query is empty")?;
|
||||
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let external = call.flag("include_external", false)?;
|
||||
let host = call.host();
|
||||
@@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
|
||||
let root = call.root().to_path_buf();
|
||||
let absolute = |item: &SearchItem| {
|
||||
let path = Path::new(&item.file_path);
|
||||
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
|
||||
};
|
||||
fill_lines(&call, &mut items, absolute, around).await;
|
||||
let indexing = indexing_before || project.index.status().indexing;
|
||||
let partial_result_reason = if indexing {
|
||||
Some(INDEXING_PARTIAL_RESULT_REASON)
|
||||
@@ -869,6 +987,17 @@ mod tests {
|
||||
assert_eq!(byte_at("ab", 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lines_are_cut_around_the_match() {
|
||||
assert_eq!(snippet("short line", 0, 5), "short line");
|
||||
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
|
||||
let cut = snippet(&line, 400, 406);
|
||||
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
|
||||
let start = snippet(&line, 0, 0);
|
||||
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
|
||||
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_scopes() {
|
||||
let (_dir, root) = root();
|
||||
|
||||
@@ -313,10 +313,11 @@ pub async fn build_sync(
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
let p = project.clone();
|
||||
tokio::task::spawn_blocking(move || p.refresh_repositories());
|
||||
}
|
||||
// New source folders and dependency versions: the Project view and External Libraries reload.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
if tool == BuildTool::Maven
|
||||
&& model.maven.is_some()
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
@@ -455,7 +456,8 @@ 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). `None` when nothing
|
||||
/// 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
|
||||
/// 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)?;
|
||||
@@ -466,7 +468,19 @@ 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);
|
||||
Ok(ide_build::maven::goto_declaration(&text, &path, pos, 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))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
|
||||
@@ -114,14 +114,28 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
// A checkout backend opens the file for add; Git's hook is a no-op.
|
||||
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_vfs::create_file_bytes(&path, &data)?;
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
@@ -135,25 +149,54 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
let project = host.project(window).ok();
|
||||
let history = project.as_ref().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
|
||||
if let Some(h) = history {
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in &paths {
|
||||
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
ide_workspace::file_ops::delete(&path)?;
|
||||
// A checkout backend opens the paths for delete; Git's hook is a no-op.
|
||||
if let Some(p) = project {
|
||||
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
|
||||
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::copy(&from, &to)?;
|
||||
// A copy is an add at the destination; Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
|
||||
{
|
||||
tracing::debug!("vcs files_added for {}: {e}", to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
|
||||
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || {
|
||||
ide_workspace::file_ops::rename(&from, &to)?;
|
||||
// A checkout backend records the rename so history follows the file;
|
||||
// Git's hook is a no-op.
|
||||
if let Some(p) = project
|
||||
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
|
||||
{
|
||||
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
@@ -48,7 +48,7 @@ async fn resolve_launch(
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated, prefer_maven) = {
|
||||
let (model, external, mut delegated, prefer_maven, is_maven) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
@@ -68,13 +68,29 @@ async fn resolve_launch(
|
||||
prefer_maven,
|
||||
);
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated, prefer_maven)
|
||||
(model, external, delegated, prefer_maven, is_maven)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut external_build =
|
||||
if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
|
||||
if build && !delegated && is_maven {
|
||||
// JDT runs an annotation processor again over sources it generated in the same build
|
||||
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
|
||||
// javac doesn't, so Maven builds the module.
|
||||
tracing::info!("Annotation processing failed in JDT; building with Maven");
|
||||
delegated = true;
|
||||
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
|
||||
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
|
||||
}
|
||||
if delegated {
|
||||
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
}
|
||||
}
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -338,10 +354,60 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
});
|
||||
}
|
||||
|
||||
/// An annotation processor's error for writing a file this build already wrote (JDT's
|
||||
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
|
||||
fn processor_recreated_a_file(message: &str) -> bool {
|
||||
message.contains("FilerException")
|
||||
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
|
||||
}
|
||||
|
||||
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
|
||||
let before = info.errors.len();
|
||||
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
|
||||
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_a_processor_recreating_its_output() {
|
||||
assert!(processor_recreated_a_file(
|
||||
"Internal error in the mapping processor: java.lang.RuntimeException: \
|
||||
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
|
||||
));
|
||||
assert!(processor_recreated_a_file(
|
||||
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
|
||||
));
|
||||
assert!(!processor_recreated_a_file("Source file already created"));
|
||||
assert!(!processor_recreated_a_file("cannot find symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
|
||||
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/a/A.java".into(),
|
||||
line: 1,
|
||||
column: 1,
|
||||
message: message.into(),
|
||||
};
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 2,
|
||||
errors: vec![
|
||||
error(
|
||||
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
|
||||
),
|
||||
error("cannot find symbol"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
drop_processor_recreate_errors(&mut info);
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors.len(), 1);
|
||||
assert_eq!(info.errors[0].message, "cannot find symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
|
||||
|
||||
@@ -234,7 +234,19 @@ pub async fn start_request(
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
let mut config = config;
|
||||
let agent = match host.project(window) {
|
||||
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
|
||||
Err(_) => None,
|
||||
};
|
||||
let on_output = match &agent {
|
||||
Some(agent) => agent.wrap_output(on_output),
|
||||
None => on_output,
|
||||
};
|
||||
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
|
||||
if let Some(agent) = &agent {
|
||||
agent.started(&result);
|
||||
}
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
@@ -249,6 +261,7 @@ async fn start_inner(
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
agent: Option<&crate::mcp::run::agent::Launch>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -282,6 +295,9 @@ async fn start_inner(
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
sink = agent.wrap_sink(sink);
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -450,6 +466,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
host.mcp_agent_runs.stopping(id);
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
@@ -461,7 +478,9 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
/// Input the user typed into a run (the agent's goes to the process directly).
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
host.mcp_agent_runs.user_input(id, data);
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ impl LogAccess {
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.with_log_repo_at(at, f)
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
@@ -280,6 +285,16 @@ fn per_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `per_repo` through the backend-agnostic Changes-view surface.
|
||||
fn per_backend(
|
||||
project: &Project,
|
||||
paths: &[PathBuf],
|
||||
recorded: Option<&str>,
|
||||
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
|
||||
}
|
||||
|
||||
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
|
||||
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
|
||||
let repos = project.repos();
|
||||
@@ -348,7 +363,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -360,7 +375,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| {
|
||||
return project.with_log_backend_of(&path, |r| {
|
||||
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
|
||||
});
|
||||
}
|
||||
@@ -383,7 +398,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
}
|
||||
external
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
@@ -403,7 +418,7 @@ pub async fn vcs_blame(
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
project.with_backend_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
})
|
||||
@@ -424,7 +439,7 @@ pub async fn vcs_block_history(
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
project.with_backend_of(&path, |r| {
|
||||
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
|
||||
})
|
||||
})
|
||||
@@ -441,7 +456,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
r.log_cancellable(&query, &cancel)
|
||||
(r as &dyn Backend).log(&query, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
@@ -540,7 +555,7 @@ pub async fn vcs_commit_files(
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -551,7 +566,7 @@ pub async fn vcs_commits_changes(
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
@@ -567,7 +582,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
@@ -602,7 +617,7 @@ pub async fn vcs_add_to_ignore(
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
@@ -614,7 +629,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit(
|
||||
@@ -628,8 +643,8 @@ pub async fn vcs_commit(
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
project.with_backend_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -886,7 +901,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1201,7 +1216,7 @@ pub async fn vcs_diff_with_worktree(
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -1213,7 +1228,7 @@ pub async fn vcs_diff_revisions(
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
|
||||
|
||||
@@ -1276,12 +1276,13 @@ pub(super) fn commit_draft(
|
||||
};
|
||||
// Both callers have checked that the window's project is trusted.
|
||||
r.set_execution_allowed(true);
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
let backend = &r as &dyn ide_vcs::Backend;
|
||||
let pending = backend
|
||||
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
|
||||
.map_err(|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
})?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
@@ -1530,6 +1531,19 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn open_diff(
|
||||
&self,
|
||||
client: &str,
|
||||
diff: ide_plugin::ContentDiff,
|
||||
file_name: Option<String>,
|
||||
) -> Result<(), HostError> {
|
||||
super::execution::validate_diff(&diff)?;
|
||||
if let Some(name) = &file_name {
|
||||
super::execution::label(name, 256)?;
|
||||
}
|
||||
self.shared.sink.emit(Some(client), PluginEvent::OpenDiff { plugin_id: self.id().into(), diff, file_name });
|
||||
Ok(())
|
||||
}
|
||||
fn reply_content_tab(
|
||||
&self,
|
||||
client: &str,
|
||||
@@ -4111,6 +4125,11 @@ impl PluginApi {
|
||||
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
|
||||
return Err(HostError::Invalid("decoration limit exceeded".into()));
|
||||
}
|
||||
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
|
||||
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
|
||||
{
|
||||
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
|
||||
}
|
||||
items.retain(|d| decoration_style(&d.style));
|
||||
if items.iter().any(|d| d.range.start > d.range.end) {
|
||||
return Err(HostError::Invalid("reversed decoration range".into()));
|
||||
@@ -4129,7 +4148,7 @@ impl PluginApi {
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
file: file.filter(|f| decoration_style(&f.style)),
|
||||
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4301,10 +4320,33 @@ mod workbench_tests {
|
||||
api.file_decorations(
|
||||
Some(&path),
|
||||
"tree",
|
||||
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
|
||||
Some(ide_plugin::FileDecoration {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let note = |text: &str| ide_plugin::FileDecoration {
|
||||
badge: None,
|
||||
style: String::new(),
|
||||
tooltip: Some("why".into()),
|
||||
note: Some(text.into()),
|
||||
};
|
||||
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
|
||||
let notes = |api: &PluginApi| {
|
||||
api.shared.decoration_sets()["test"]
|
||||
.iter()
|
||||
.filter(|s| s.tree && s.set_id == "notes")
|
||||
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
|
||||
for bad in ["", "two\nlines", &"x".repeat(201)] {
|
||||
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
|
||||
}
|
||||
api.shared.clear_plugin("test", true);
|
||||
assert!(api.shared.decoration_sets().is_empty());
|
||||
}
|
||||
|
||||
@@ -72,14 +72,19 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
validate_diff(diff)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -47,6 +47,48 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
/// What [`RemotePlugins::register`] adds to a server's commands.
|
||||
pub const COMMANDS: &[&str] = &[
|
||||
SYNC_PACKAGES,
|
||||
"plugin_run_command",
|
||||
"plugin_set_ui_locale",
|
||||
"plugin_select_tests",
|
||||
"plugin_parse_test_results",
|
||||
"plugin_terminal_session_close",
|
||||
"plugin_terminal_session_spawn",
|
||||
"plugin_prepare_debug_run",
|
||||
"plugin_finish_operation",
|
||||
"plugin_content_tab_action",
|
||||
"plugin_post_events",
|
||||
"plugin_event_kinds",
|
||||
"plugin_status_items",
|
||||
"plugin_context_keys",
|
||||
"plugin_cancel_task",
|
||||
"plugin_answer_prompt",
|
||||
"plugin_choose_program",
|
||||
"plugin_program",
|
||||
"plugin_answer_command",
|
||||
"plugin_answer_commit",
|
||||
"plugin_commit_checks",
|
||||
"mcp_set_open_editors",
|
||||
"mcp_set_focused_editor",
|
||||
"mcp_report_problems",
|
||||
"plugin_decoration_sets",
|
||||
"plugin_set_active_file",
|
||||
"plugin_view_render",
|
||||
"plugin_view_event",
|
||||
"plugin_service_detail",
|
||||
"plugin_service_log",
|
||||
"plugin_navigation_files",
|
||||
"plugin_navigation_invalidate",
|
||||
"plugin_navigation_index",
|
||||
"plugin_navigation_goto",
|
||||
"plugin_navigation_markers",
|
||||
"plugin_endpoints",
|
||||
"plugin_run_targets",
|
||||
"plugin_answer_run_target",
|
||||
"plugin_line_markers",
|
||||
];
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
@@ -134,20 +176,25 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
}
|
||||
|
||||
/// Whether a plugin event means something to a remote window (the desktop
|
||||
/// owns the plugin list, themes and consent).
|
||||
/// owns the plugin list, themes and consent). The desktop checks it again on
|
||||
/// arrival: a server may send anything, and only what acts on the remote project
|
||||
/// passes. A command may only be another plugin command, which runs on the server.
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
if let PluginEvent::ExecuteCommand { id, .. } = event {
|
||||
return plugin_command(id);
|
||||
}
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::RunTargetsChanged
|
||||
| PluginEvent::WindowRequest { .. }
|
||||
| PluginEvent::ContentTabReply { .. }
|
||||
| PluginEvent::OpenDiff { .. }
|
||||
| PluginEvent::DecorationsChanged { .. }
|
||||
| PluginEvent::EditorOpen { .. }
|
||||
| PluginEvent::EditorRanges { .. }
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
@@ -163,7 +210,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLogTab { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::PromptWaiting { .. }
|
||||
@@ -173,6 +219,13 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
|
||||
fn plugin_command(id: &str) -> bool {
|
||||
id.strip_prefix("plugin.")
|
||||
.and_then(|rest| rest.split_once('.'))
|
||||
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
|
||||
}
|
||||
|
||||
// ---- desktop side -----------------------------------------------------------
|
||||
|
||||
impl PluginHost {
|
||||
@@ -951,4 +1004,43 @@ mod tests {
|
||||
assert!(valid_id("ai-completion"));
|
||||
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
|
||||
}
|
||||
|
||||
fn execute(id: &str) -> PluginEvent {
|
||||
PluginEvent::ExecuteCommand {
|
||||
request_id: "1".into(),
|
||||
id: id.into(),
|
||||
arg: Some(r"\\host.example\share\pkg".into()),
|
||||
chain: vec!["tools".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
|
||||
assert!(forwarded(&execute("plugin.tools.build")));
|
||||
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
|
||||
for id in [
|
||||
"plugins.installFromDisk",
|
||||
"project.revokeTrust",
|
||||
"project.removeTrustedLocation",
|
||||
"ssh.startSession",
|
||||
"file.openProjectPath",
|
||||
"terminal.runCommand",
|
||||
"plugin.",
|
||||
"plugin.tools",
|
||||
"plugin..build",
|
||||
"plugin.tools.",
|
||||
] {
|
||||
assert!(!forwarded(&execute(id)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_does_not_open_desktop_folders() {
|
||||
let open = PluginEvent::OpenProject {
|
||||
plugin_id: "tools".into(),
|
||||
path: r"\\host.example\share\project".into(),
|
||||
new_window: true,
|
||||
};
|
||||
assert!(!forwarded(&open));
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,9 @@ pub enum PluginEvent {
|
||||
/// Reply to a content action in this window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
|
||||
/// Open two texts in this window's diff viewer.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenDiff { plugin_id: String, diff: ide_plugin::ContentDiff, file_name: Option<String> },
|
||||
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_syntax::StructureService;
|
||||
use ide_text::{AppliedChange, ByteEdit};
|
||||
use ide_vcs::{Repo, Repos};
|
||||
use ide_vcs::{Backend, Repo, Repos};
|
||||
use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
@@ -971,6 +971,11 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// `with_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
if self.is_trusted() {
|
||||
@@ -980,6 +985,40 @@ impl Project {
|
||||
f(repo)
|
||||
}
|
||||
|
||||
/// `with_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_at<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_log_repo_of` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_log_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_log_repo_of(path, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// `with_repo` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend<T>(&self, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_backend_at(None, f)
|
||||
}
|
||||
|
||||
/// `with_repo_recorded` through the backend-agnostic Changes-view surface.
|
||||
pub fn with_backend_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
self.with_repo_recorded(at, operation, |r| f(r as &dyn Backend))
|
||||
}
|
||||
|
||||
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
|
||||
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
@@ -1004,7 +1043,7 @@ impl Project {
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
if let Ok(changed) = self.with_backend_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
|
||||
+106
-3
@@ -185,15 +185,15 @@ impl Dispatcher {
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
ops::fs::create_file_bytes(&c.host, &c.window, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy_merge", |c| async move {
|
||||
ops::fs::copy_merge(
|
||||
&c.host,
|
||||
@@ -298,6 +298,56 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
|
||||
Arc::new(PeerSink { peer, window })
|
||||
}
|
||||
|
||||
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
|
||||
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
|
||||
/// groups) never comes from it.
|
||||
pub fn client_accepts(event: &IdeEvent) -> bool {
|
||||
match event {
|
||||
IdeEvent::McpRequest { .. }
|
||||
| IdeEvent::McpRequestClosed { .. }
|
||||
| IdeEvent::WindowGroupsChanged
|
||||
| IdeEvent::WindowFullscreenChanged
|
||||
| IdeEvent::UpdateStatusChanged { .. }
|
||||
| IdeEvent::UpdateAvailable { .. } => false,
|
||||
IdeEvent::DbSourcesChanged
|
||||
| IdeEvent::ProjectTrustChanged { .. }
|
||||
| IdeEvent::FsChanged { .. }
|
||||
| IdeEvent::ExcludedDirsChanged { .. }
|
||||
| IdeEvent::DocPatched { .. }
|
||||
| IdeEvent::DocDirty { .. }
|
||||
| IdeEvent::WorkspaceEditApplied { .. }
|
||||
| IdeEvent::DocLanguage { .. }
|
||||
| IdeEvent::Diagnostics { .. }
|
||||
| IdeEvent::Task { .. }
|
||||
| IdeEvent::IndexState { .. }
|
||||
| IdeEvent::VcsChanged { .. }
|
||||
| IdeEvent::LanguageServer { .. }
|
||||
| IdeEvent::LanguageServerWarning { .. }
|
||||
| IdeEvent::LanguageServerRemoved { .. }
|
||||
| IdeEvent::LspRefresh { .. }
|
||||
| IdeEvent::TerminalExited { .. }
|
||||
| IdeEvent::RunExited { .. }
|
||||
| IdeEvent::Debug { .. }
|
||||
| IdeEvent::Notify { .. }
|
||||
| IdeEvent::SettingsChanged { .. }
|
||||
| IdeEvent::ProjectRootsChanged { .. }
|
||||
| IdeEvent::Askpass { .. }
|
||||
| IdeEvent::AskpassDone { .. }
|
||||
| IdeEvent::NotebookKernel { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
|
||||
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
|
||||
pub fn client_commands() -> Vec<&'static str> {
|
||||
let mut out = Dispatcher::standard().commands();
|
||||
out.extend(crate::plugins::remote_host::COMMANDS);
|
||||
out.push(crate::plugins::remote::SYNC_COMMAND);
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
struct PeerSink {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
window: Option<String>,
|
||||
@@ -364,3 +414,56 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
|
||||
|
||||
#[test]
|
||||
fn a_server_cannot_raise_desktop_prompts() {
|
||||
let confirm = McpUiRequest::Confirm {
|
||||
client: "agent".into(),
|
||||
tool: "run".into(),
|
||||
subject: "rm -rf".into(),
|
||||
project: "/srv/app".into(),
|
||||
detail: None,
|
||||
};
|
||||
let update = ide_core::updates::UpdateInfo {
|
||||
version: "9.9.9".into(),
|
||||
current_version: "0.1.0".into(),
|
||||
notes: None,
|
||||
date: None,
|
||||
};
|
||||
for event in [
|
||||
IdeEvent::McpRequest { id: 1, request: confirm },
|
||||
IdeEvent::McpRequestClosed { id: 1 },
|
||||
IdeEvent::UpdateAvailable { update },
|
||||
IdeEvent::UpdateStatusChanged {
|
||||
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
|
||||
},
|
||||
IdeEvent::WindowGroupsChanged,
|
||||
IdeEvent::WindowFullscreenChanged,
|
||||
] {
|
||||
assert!(!client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_reports_its_project_to_the_desktop() {
|
||||
for event in [
|
||||
IdeEvent::FsChanged {
|
||||
created: vec!["/srv/app/a".into()],
|
||||
modified: vec![],
|
||||
removed: vec![],
|
||||
ignore_rules: vec![],
|
||||
},
|
||||
IdeEvent::VcsChanged { root: "/srv/app".into() },
|
||||
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
|
||||
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
|
||||
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
|
||||
] {
|
||||
assert!(client_accepts(&event), "{event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,15 @@ pub struct TrustStore {
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
remote_file: PathBuf,
|
||||
remote: Mutex<IdeResult<RemoteDecisions>>,
|
||||
}
|
||||
|
||||
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct RemoteDecisions {
|
||||
trusted: Vec<String>,
|
||||
restricted: Vec<String>,
|
||||
}
|
||||
|
||||
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
|
||||
@@ -40,7 +49,37 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
let remote_file = data.join("remote-project-trust.json");
|
||||
let remote = match std::fs::read(&remote_file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
|
||||
}
|
||||
|
||||
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
|
||||
/// Commands a remote window runs here follow it, not the server's own trust.
|
||||
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
|
||||
let remote = self.remote.lock().clone()?;
|
||||
Ok(if remote.restricted.iter().any(|l| l == location) {
|
||||
Some(false)
|
||||
} else if remote.trusted.iter().any(|l| l == location) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
|
||||
let mut state = self.remote.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.trusted.retain(|l| l != location);
|
||||
next.restricted.retain(|l| l != location);
|
||||
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
|
||||
persist(&self.remote_file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -133,7 +172,7 @@ impl TrustStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -372,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_trust_status"
|
||||
| "remote_trust_set"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
@@ -526,4 +567,18 @@ mod tests {
|
||||
fn restricted_mode_allows_clipboard_write_text() {
|
||||
assert!(super::restricted_command_allowed("clipboard_write_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_project_decisions_are_kept_on_this_machine() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
let store = load();
|
||||
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
|
||||
assert_eq!(store.remote_decision(here).unwrap(), None);
|
||||
store.decide_remote(here, true).unwrap();
|
||||
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
|
||||
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
|
||||
store.decide_remote(here, false).unwrap();
|
||||
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,16 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
|
||||
assert_eq!(found["more"], true);
|
||||
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
|
||||
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
|
||||
// The matched line comes with the result (unsaved text included), and lines around it on request.
|
||||
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
|
||||
assert!(all["items"][0].get("contextAfter").is_none());
|
||||
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
|
||||
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
|
||||
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
|
||||
assert_eq!(before["items"][0]["lineText"], "needle needle");
|
||||
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
|
||||
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
|
||||
assert!(bad.contains("context must be"), "{bad}");
|
||||
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
|
||||
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
|
||||
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
|
||||
@@ -453,3 +463,166 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
|
||||
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
|
||||
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn check_files_says_why_a_file_could_not_be_checked() {
|
||||
let h = Harness::new();
|
||||
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
|
||||
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
|
||||
h.trust().await;
|
||||
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
|
||||
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
|
||||
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
|
||||
assert_eq!(files["files"][0]["problems"], json!([]));
|
||||
assert_eq!(files["files"][1]["status"], "failed", "{files}");
|
||||
assert!(files["files"][1]["error"].is_string());
|
||||
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
|
||||
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
|
||||
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
|
||||
let none = h.error("check_files", json!({"filePaths": []})).await;
|
||||
assert!(none.contains("at least one file"), "{none}");
|
||||
}
|
||||
|
||||
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
|
||||
/// agent tab's configuration (shell from the default) and says whether it started. Returns
|
||||
/// the configurations it started, for reruns.
|
||||
#[cfg(unix)]
|
||||
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
|
||||
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut handled = std::collections::HashSet::new();
|
||||
loop {
|
||||
let request = events.lock().unwrap().iter().find_map(|e| match e {
|
||||
IdeEvent::McpRequest {
|
||||
id,
|
||||
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
|
||||
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((id, name, command, cwd, env)) = request else {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
continue;
|
||||
};
|
||||
handled.insert(id);
|
||||
let mut config = ide_run::RunConfig {
|
||||
id: format!("mcp-agent:{name}"),
|
||||
name: name.clone(),
|
||||
kind: "shell".into(),
|
||||
command,
|
||||
args: Vec::new(),
|
||||
cwd: Some(cwd),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: true,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
|
||||
config.extras.target = Some(String::new());
|
||||
out.lock().unwrap().push(config.clone());
|
||||
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
|
||||
let error = result.err().map(|e| e.message);
|
||||
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
|
||||
}
|
||||
});
|
||||
started
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
|
||||
let id = format!("mcp-agent:{name}");
|
||||
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
|
||||
let h = Harness::new();
|
||||
h.trust().await;
|
||||
h.host.mcp_requests.set_ready("main", true);
|
||||
h.host
|
||||
.global_settings
|
||||
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
|
||||
.unwrap();
|
||||
let started = run_tool_window(&h);
|
||||
|
||||
// A command that finishes within the wait, in a tab named with the prefix.
|
||||
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
|
||||
assert_eq!(done["name"], "_agent_check", "{done}");
|
||||
assert_eq!(done["state"], "failed", "{done}");
|
||||
assert_eq!(done["exitCode"], 3);
|
||||
assert_eq!(done["startedBy"], "agent");
|
||||
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
|
||||
assert!(done.get("interventions").is_none());
|
||||
|
||||
// The finished name runs the next command; a running one is refused, never stopped.
|
||||
let running =
|
||||
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
|
||||
assert_eq!(running["state"], "running", "{running}");
|
||||
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
|
||||
assert_eq!(matched["waitEnded"], "matched", "{matched}");
|
||||
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
|
||||
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
|
||||
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
|
||||
|
||||
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
|
||||
let process = agent_process(&h, "_agent_check");
|
||||
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
|
||||
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
|
||||
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
|
||||
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
|
||||
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
|
||||
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
|
||||
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
|
||||
|
||||
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
|
||||
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
|
||||
assert!(stopped.get("interventions").is_none());
|
||||
|
||||
// The user's Stop, rerun and closing the tab are reported with their time.
|
||||
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
|
||||
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
|
||||
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
|
||||
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
|
||||
|
||||
let config = started.lock().unwrap().last().cloned().unwrap();
|
||||
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
|
||||
let status = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert_eq!(status["startedBy"], "user", "{status}");
|
||||
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
|
||||
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
|
||||
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
|
||||
let closed = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert!(closed["tabClosedAt"].is_string(), "{closed}");
|
||||
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
|
||||
|
||||
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
|
||||
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
|
||||
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
|
||||
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
|
||||
let runs = h.json("list_runs", json!({})).await;
|
||||
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
|
||||
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
|
||||
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
|
||||
|
||||
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
|
||||
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
|
||||
let lines =
|
||||
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
|
||||
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
|
||||
assert_eq!(lines["truncatedLines"], 1);
|
||||
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
|
||||
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
|
||||
|
||||
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
|
||||
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
|
||||
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
|
||||
assert!(key.contains("runs on pipes"), "{key}");
|
||||
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
|
||||
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
|
||||
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
|
||||
}
|
||||
@@ -6352,6 +6352,26 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
|
||||
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_plugin_command_opens_a_diff_in_its_window() {
|
||||
let f = execution_fixture();
|
||||
f.take_events();
|
||||
f.run("execution", "diff", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| {
|
||||
f.events.lock().iter().any(|(client, event)| {
|
||||
client.as_deref() == Some("main")
|
||||
&& matches!(event, PluginEvent::OpenDiff { plugin_id, diff, file_name }
|
||||
if plugin_id == "execution"
|
||||
&& diff.left == "before\n"
|
||||
&& diff.right == "after\n"
|
||||
&& diff.title.as_deref() == Some("Review")
|
||||
&& file_name.as_deref() == Some("src/review.rs"))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_fixture_exports_test_selection() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -455,3 +455,29 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
// As `openidea-server` assembles its commands.
|
||||
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
|
||||
assert_eq!(rpc::client_commands(), dispatcher.commands());
|
||||
assert!(rpc::client_commands().contains(&"term_spawn"));
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -83,10 +83,10 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
};
|
||||
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
|
||||
std::fs::write(meta(&original), original_metadata).unwrap();
|
||||
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
|
||||
ops::fs::rename(&host, "main", original.clone(), renamed.clone()).await.unwrap();
|
||||
assert!(!original.exists() && !meta(&original).exists());
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
|
||||
ops::fs::copy(&host, "main", renamed.clone(), copied.clone()).await.unwrap();
|
||||
assert!(copied.exists());
|
||||
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
|
||||
@@ -21,6 +21,9 @@ ignore.workspace = true
|
||||
globset.workspace = true
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
|
||||
//! file (ignored state, size limit, record layout) is decided here as well as in
|
||||
//! `ide-language`, so the persistent index is rebuilt when either changes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
|
||||
println!("cargo:rerun-if-changed={}", src.display());
|
||||
let mut files = Vec::new();
|
||||
collect(&src, &mut files);
|
||||
files.sort();
|
||||
let mut h = blake3::Hasher::new();
|
||||
for file in files {
|
||||
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ impl Store {
|
||||
}
|
||||
};
|
||||
let store = Self { db: RwLock::new(db) };
|
||||
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
|
||||
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
|
||||
match store.meta("stamp") {
|
||||
Ok(Some(v)) if v == stamp.as_bytes() => {}
|
||||
_ => {
|
||||
|
||||
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-prisma-io = "1.6.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
|
||||
//! sources, queries, language definitions and the locked tree-sitter crates)
|
||||
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
|
||||
//! extraction untouched.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let workspace = manifest.join("../..");
|
||||
let lock = workspace.join("Cargo.lock");
|
||||
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
|
||||
|
||||
let mut h = blake3::Hasher::new();
|
||||
for dir in &inputs {
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
let mut files = Vec::new();
|
||||
collect(dir, &mut files);
|
||||
files.sort();
|
||||
for file in files {
|
||||
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
|
||||
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&std::fs::read(&file).unwrap());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", lock.display());
|
||||
match std::fs::read_to_string(&lock) {
|
||||
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
|
||||
// Built outside the workspace: fall back to the crate version.
|
||||
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
|
||||
};
|
||||
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
|
||||
}
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `name version checksum` of every locked `tree-sitter*` package.
|
||||
fn tree_sitter_packages(lock: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for package in lock.split("[[package]]") {
|
||||
let field = |key: &str| {
|
||||
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
|
||||
};
|
||||
if field("name").starts_with("tree-sitter") {
|
||||
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -132,29 +132,11 @@ impl Syntax {
|
||||
SPECS.iter().map(|s| s.name)
|
||||
}
|
||||
|
||||
/// Changes whenever extraction output may change (grammar set, queries,
|
||||
/// crate version); persistent caches of [`Syntax::tags`] key on it.
|
||||
/// Changes whenever extraction output may change (this crate's sources,
|
||||
/// queries, language definitions, tree-sitter crate versions; see
|
||||
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
static FP: OnceLock<String> = OnceLock::new();
|
||||
FP.get_or_init(|| {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(env!("CARGO_PKG_VERSION").as_bytes());
|
||||
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
|
||||
h.update(b"web-definitions-1");
|
||||
for s in SPECS {
|
||||
h.update(s.name.as_bytes());
|
||||
h.update(&Language::new(s.language).abi_version().to_le_bytes());
|
||||
h.update(s.outline.as_bytes());
|
||||
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
|
||||
for k in list {
|
||||
h.update(k.as_bytes());
|
||||
h.update(b"\0");
|
||||
}
|
||||
}
|
||||
}
|
||||
h.finalize().to_hex()[..16].to_owned()
|
||||
})
|
||||
.clone()
|
||||
env!("SYNTAX_SOURCE_HASH").to_owned()
|
||||
}
|
||||
|
||||
/// Whether a tree-sitter grammar is bundled for this language.
|
||||
|
||||
@@ -22,6 +22,9 @@ use std::time::{Duration, Instant};
|
||||
/// How long Inspect Code waits for a server that is still starting (jdtls,
|
||||
/// rust-analyzer on a large workspace).
|
||||
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
|
||||
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
|
||||
/// syntax first, semantics later).
|
||||
const CHECK_SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct InspectOptions {
|
||||
@@ -48,6 +51,29 @@ pub trait InspectHost: Send + Sync + 'static {
|
||||
fn close(&self, doc: DocId);
|
||||
}
|
||||
|
||||
/// One language server's part in a [`DocumentCheck`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CheckedServer {
|
||||
pub name: String,
|
||||
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
|
||||
pub status: ide_core::event::ServerStatus,
|
||||
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
|
||||
pub message: Option<String>,
|
||||
/// Its diagnostics are in the result.
|
||||
pub checked: bool,
|
||||
/// It was still analysing (or had not published for this change) when the wait ended.
|
||||
pub incomplete: bool,
|
||||
/// Asking it failed.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of a document, with every language server that serves it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DocumentCheck {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub servers: Vec<CheckedServer>,
|
||||
}
|
||||
|
||||
/// What became of one file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FileInspection {
|
||||
@@ -198,6 +224,96 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
|
||||
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
|
||||
/// servers the document was saved, since checks that run on save (rust-analyzer's
|
||||
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
|
||||
/// to be ready, to answer for this text and to finish the background work it started, up
|
||||
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
|
||||
/// problems" from "nothing could check".
|
||||
pub async fn check_document(
|
||||
&self,
|
||||
doc: DocId,
|
||||
changed: Option<Instant>,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<DocumentCheck> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let ready = tokio::select! {
|
||||
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
// Once the servers have the document: a save sent while one starts would not reach it.
|
||||
self.did_save(doc, snap.text.as_str());
|
||||
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
|
||||
let mut report = CheckedServer {
|
||||
name: server.name.clone(),
|
||||
status: server.status(),
|
||||
message: server.status_message(),
|
||||
checked: false,
|
||||
incomplete: false,
|
||||
error: None,
|
||||
};
|
||||
let Some(run) = run else { return Some((report, Vec::new())) };
|
||||
let t = Target { server: server.clone(), run };
|
||||
if !t.caps().restriction.diagnostics() {
|
||||
return None;
|
||||
}
|
||||
let pull = t.caps().has("diagnosticProvider");
|
||||
let mut since = changed;
|
||||
let raw = loop {
|
||||
// A pull asks for this text; a push server is waited on to publish for it.
|
||||
let raw = if pull {
|
||||
server.wait_idle(left(), cancel).await;
|
||||
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
|
||||
} else {
|
||||
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
|
||||
wait_for_publish(server, &snap.path, since, opts, cancel).await
|
||||
};
|
||||
// The save may have started work (a `cargo check`) that publishes when it ends.
|
||||
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
|
||||
break raw;
|
||||
}
|
||||
if !server.wait_idle(left(), cancel).await {
|
||||
break raw.map(|_| server.diagnostics_for(&snap.path));
|
||||
}
|
||||
// Idle: its last publish is final once quiet.
|
||||
since = None;
|
||||
};
|
||||
report.status = server.status();
|
||||
match raw {
|
||||
Ok(items) => {
|
||||
report.checked = true;
|
||||
let fresh =
|
||||
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
|
||||
report.incomplete = !fresh || server.is_busy();
|
||||
let conv = self.conv(t.enc());
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
|
||||
.collect::<Vec<_>>();
|
||||
Some((report, items))
|
||||
}
|
||||
Err(e) => {
|
||||
report.error = Some(e.message);
|
||||
Some((report, Vec::new()))
|
||||
}
|
||||
}
|
||||
});
|
||||
let results = tokio::select! {
|
||||
r = futures::future::join_all(per_server) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let mut out = DocumentCheck::default();
|
||||
for (report, items) in results.into_iter().flatten() {
|
||||
out.servers.push(report);
|
||||
out.diagnostics.extend(items);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn pull_diagnostics(
|
||||
&self,
|
||||
t: &Target,
|
||||
|
||||
@@ -40,7 +40,7 @@ mod vue_navigation;
|
||||
mod vue_refs;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
|
||||
@@ -658,6 +658,24 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
|
||||
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
|
||||
/// cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let notified = self.phase_notify.notified();
|
||||
if !self.is_busy() || self.running().is_none() {
|
||||
return !self.is_busy();
|
||||
}
|
||||
tokio::select! {
|
||||
() = notified => {}
|
||||
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
|
||||
() = cancel.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transiently opened files ----
|
||||
|
||||
/// Registers a holder of the transient file `path` on instance `generation`;
|
||||
|
||||
@@ -2178,6 +2178,57 @@ async fn missing_server_reports_once() {
|
||||
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
let (doc, path) = h.open("a.mk", "bad\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
|
||||
let (server, uri) = (s.clone(), uri_of(&path));
|
||||
let publisher = tokio::spawn(async move {
|
||||
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
|
||||
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
|
||||
"severity": 1, "message": "on save" });
|
||||
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
|
||||
});
|
||||
let cancel = CancellationToken::new();
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
|
||||
publisher.await.unwrap();
|
||||
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
|
||||
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
|
||||
|
||||
// A change the server never answered: what it has is reported as not fresh.
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
|
||||
assert_eq!(stale.diagnostics.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_reports_servers_that_cannot_check() {
|
||||
let cancel = CancellationToken::new();
|
||||
let h = harness(&[("ghost", json!({}))]);
|
||||
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("ghost", ServerStatus::Missing).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert!(check.diagnostics.is_empty());
|
||||
let s = &check.servers[0];
|
||||
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
|
||||
assert_eq!(s.message.as_deref(), Some("install ghost"));
|
||||
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Misconfigured).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
|
||||
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
|
||||
assert!(!check.servers[0].checked);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn launch_error_fails_without_starting_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
+249
-22
@@ -3,17 +3,39 @@
|
||||
use crate::discovery::Discovery;
|
||||
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use http_body_util::{BodyExt, Either, Full, Limited};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::{JoinError, JoinHandle};
|
||||
use tokio_util::sync::DropGuard;
|
||||
|
||||
/// Largest request body.
|
||||
const MAX_BODY: usize = 8 << 20;
|
||||
/// A call still running after this long is answered on an event stream (when the client
|
||||
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
|
||||
/// response has not started (Claude Code after a minute).
|
||||
#[cfg(not(test))]
|
||||
const STREAM_AFTER: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STREAM_AFTER: Duration = Duration::from_millis(100);
|
||||
/// How often a streamed call reports that it is still running: progress notifications reset
|
||||
/// the client's idle timeout (Claude Code's is five minutes).
|
||||
#[cfg(not(test))]
|
||||
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
#[cfg(test)]
|
||||
const HEARTBEAT: Duration = Duration::from_millis(100);
|
||||
|
||||
type Handled = Result<Option<Value>, Value>;
|
||||
type Body = Either<Full<Bytes>, EventStream>;
|
||||
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
@@ -140,15 +162,11 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
req: Request<Incoming>,
|
||||
shared: Arc<Shared>,
|
||||
scope: String,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
|
||||
Ok(respond(req, &shared, scope).await)
|
||||
}
|
||||
|
||||
type Reply = Response<Full<Bytes>>;
|
||||
type Reply = Response<Body>;
|
||||
|
||||
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
|
||||
if req.uri().path() != "/mcp" {
|
||||
@@ -163,7 +181,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
return response;
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
// No server-sent event streams: every answer is in the POST's response.
|
||||
// No stream of server-initiated messages: every answer is in the POST's response.
|
||||
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
|
||||
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||
return response;
|
||||
@@ -175,25 +193,41 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
|
||||
let project = project_of(&req);
|
||||
let streams =
|
||||
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
|
||||
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
|
||||
};
|
||||
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let message = serde_json::from_slice::<Value>(&body).ok();
|
||||
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
|
||||
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
|
||||
let response_scope = scope.clone();
|
||||
let registry = shared.registry.clone();
|
||||
let shared = shared.clone();
|
||||
let cancel = shared.cancel.child_token();
|
||||
let _guard = cancel.clone().drop_guard();
|
||||
let handled = tokio::task::spawn_blocking(move || {
|
||||
// Cancels the call when the client goes away (the response is dropped).
|
||||
let guard = cancel.clone().drop_guard();
|
||||
let mut handled = tokio::task::spawn_blocking(move || {
|
||||
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
|
||||
})
|
||||
.await;
|
||||
});
|
||||
if streams && !initializing {
|
||||
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
|
||||
Ok(done) => return answer(done, false, &response_scope),
|
||||
Err(_) => return event_stream(handled, id, progress_token, guard),
|
||||
}
|
||||
}
|
||||
let done = handled.await;
|
||||
drop(guard);
|
||||
answer(done, initializing, &response_scope)
|
||||
}
|
||||
|
||||
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
|
||||
match handled {
|
||||
Ok(Ok(Some(reply))) => {
|
||||
let mut response = json(StatusCode::OK, &reply);
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
|
||||
response.headers_mut().insert("mcp-session-id", value);
|
||||
}
|
||||
response
|
||||
@@ -204,6 +238,71 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a call that is still running on an event stream: a progress notification every
|
||||
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
|
||||
/// answer. The call is cancelled as soon as the client goes away.
|
||||
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut handled => {
|
||||
let reply = match done {
|
||||
Ok(Ok(Some(reply)) | Err(reply)) => reply,
|
||||
Ok(Ok(None)) => return,
|
||||
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
|
||||
};
|
||||
let _ = tx.send(event(&reply)).await;
|
||||
return;
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
let elapsed = started.elapsed();
|
||||
let still_running = match &token {
|
||||
Some(token) => event(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": token,
|
||||
"progress": elapsed.as_secs_f64(),
|
||||
"message": format!("running for {}s", elapsed.as_secs()),
|
||||
},
|
||||
})),
|
||||
None => Bytes::from_static(b": running\n\n"),
|
||||
};
|
||||
if tx.send(still_running).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut response = Response::new(Either::Right(EventStream(rx)));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
/// One server-sent event carrying a JSON-RPC message.
|
||||
fn event(message: &Value) -> Bytes {
|
||||
Bytes::from(format!("event: message\ndata: {message}\n\n"))
|
||||
}
|
||||
|
||||
/// A `text/event-stream` body, written by [`event_stream`]'s task.
|
||||
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
|
||||
|
||||
impl hyper::body::Body for EventStream {
|
||||
type Data = Bytes;
|
||||
type Error = Infallible;
|
||||
|
||||
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
|
||||
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
@@ -242,22 +341,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
|
||||
fn plain(status: StatusCode, text: &str) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
|
||||
fn json(status: StatusCode, value: &Value) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
fn empty(status: StatusCode) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
}
|
||||
@@ -265,6 +364,134 @@ fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
|
||||
struct Slow(Arc<AtomicBool>);
|
||||
|
||||
impl ToolRegistry for Slow {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/project")]
|
||||
}
|
||||
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
_: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, crate::ToolCallError> {
|
||||
if name == "wait" {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
return Err(crate::ToolCallError::Failed("cancelled".into()));
|
||||
}
|
||||
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
Ok("done".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> (McpServer, Arc<AtomicBool>) {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let registry = Arc::new(Slow(cancelled.clone()));
|
||||
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
|
||||
}
|
||||
|
||||
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
|
||||
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
|
||||
if let Some(token) = token {
|
||||
params["_meta"] = json!({ "progressToken": token });
|
||||
}
|
||||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
|
||||
}
|
||||
|
||||
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
|
||||
let body = body.to_string();
|
||||
let port = server.port();
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let request = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
server.token(),
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
/// The whole response, lowercased headers and the body as sent (chunked or not).
|
||||
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
|
||||
let (head, body) = out.split_once("\r\n\r\n").unwrap();
|
||||
(head.to_ascii_lowercase(), body.to_owned())
|
||||
}
|
||||
|
||||
fn events(body: &str) -> Vec<Value> {
|
||||
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
|
||||
}
|
||||
|
||||
const BOTH: &str = "application/json, text/event-stream";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
|
||||
let (server, _) = start();
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
|
||||
assert!(head.contains("content-type: text/event-stream"), "{head}");
|
||||
let messages = events(&body);
|
||||
let (answer, progress) = messages.split_last().unwrap();
|
||||
assert_eq!(answer["id"], 3);
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "done");
|
||||
assert!(progress.len() >= 2, "{body}");
|
||||
for pair in progress.windows(2) {
|
||||
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
|
||||
}
|
||||
for p in progress {
|
||||
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
|
||||
}
|
||||
|
||||
// Without a progress token, comments keep the stream going.
|
||||
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
|
||||
assert!(body.contains(": running"), "{body}");
|
||||
assert_eq!(events(&body).len(), 1, "{body}");
|
||||
|
||||
// A client that takes only JSON waits for it.
|
||||
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
|
||||
let (server, cancelled) = start();
|
||||
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
|
||||
let mut head = Vec::new();
|
||||
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(n > 0, "the stream ended early");
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
|
||||
drop(stream);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !cancelled.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the call was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
|
||||
+106
-14
@@ -35,7 +35,8 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
|
||||
}
|
||||
|
||||
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
|
||||
/// writes its answer, if any, as one line to `output`.
|
||||
/// writes what it answers, if anything, one message per line to `output`: the
|
||||
/// answer, and before it the progress notifications of a long call.
|
||||
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
@@ -44,23 +45,37 @@ where
|
||||
let scope = crate::server::new_token()?;
|
||||
let mut lines = input.lines();
|
||||
let mut pending = tokio::task::JoinSet::new();
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let mut eof = false;
|
||||
loop {
|
||||
if eof && pending.is_empty() && received.is_empty() {
|
||||
break;
|
||||
}
|
||||
let reply = tokio::select! {
|
||||
line = lines.next_line(), if !eof => {
|
||||
let Some(line) = line? else { eof = true; continue };
|
||||
if line.trim().is_empty() { continue; }
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => {
|
||||
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
|
||||
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
|
||||
let (discovery, project, scope, sent) =
|
||||
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
|
||||
pending.spawn(async move {
|
||||
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
|
||||
let _ = sent.send(reply);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
|
||||
}
|
||||
}
|
||||
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
|
||||
else => break,
|
||||
Some(message) = received.recv() => Some(message),
|
||||
done = pending.join_next(), if !pending.is_empty() => {
|
||||
if let Some(Err(e)) = done {
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.to_string().as_bytes()).await?;
|
||||
@@ -72,15 +87,24 @@ where
|
||||
}
|
||||
|
||||
/// The IDE's answer to `message`, or an error response for each request in
|
||||
/// it when the IDE can't be reached.
|
||||
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope).await {
|
||||
Ok((StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
|
||||
/// with the messages before it.
|
||||
async fn relay(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope, sent).await {
|
||||
Ok(Answer::Streamed { answered: true }) => return None,
|
||||
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
|
||||
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(reply) => return Some(reply),
|
||||
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
|
||||
},
|
||||
Ok((status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
|
||||
@@ -100,9 +124,23 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Answer {
|
||||
Body(StatusCode, Bytes),
|
||||
/// The messages of an event stream went to the channel; `answered`: one was a response.
|
||||
Streamed {
|
||||
answered: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// POSTs `message` to the server in the discovery file; a message for the
|
||||
/// user when that fails.
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
async fn post(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Result<Answer, String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
@@ -125,6 +163,60 @@ async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) ->
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
let status = response.status();
|
||||
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
Ok((status, body))
|
||||
let streamed = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
|
||||
let mut body = response.into_body();
|
||||
if !streamed {
|
||||
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
return Ok(Answer::Body(status, body));
|
||||
}
|
||||
let (mut buffer, mut answered) = (Vec::new(), false);
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
|
||||
let Ok(data) = frame.into_data() else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
for data in take_events(&mut buffer) {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(&data) {
|
||||
answered |= message.get("method").is_none();
|
||||
let _ = sent.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Streamed { answered })
|
||||
}
|
||||
|
||||
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
|
||||
/// the rest. Comments and other fields are skipped.
|
||||
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
|
||||
let block: Vec<u8> = buffer.drain(..end + 2).collect();
|
||||
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
|
||||
let data: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|d| d.strip_prefix(' ').unwrap_or(d))
|
||||
.collect();
|
||||
if !data.is_empty() {
|
||||
events.push(data.join("\n"));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_events;
|
||||
|
||||
#[test]
|
||||
fn events_are_taken_whole_and_comments_skipped() {
|
||||
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
|
||||
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
|
||||
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
|
||||
buffer.extend_from_slice(b":2}\n\n");
|
||||
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ impl ToolRegistry for Registry {
|
||||
match name {
|
||||
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
|
||||
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
|
||||
"sleep" => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
|
||||
Ok("slept".into())
|
||||
}
|
||||
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
|
||||
}
|
||||
}
|
||||
@@ -252,6 +256,20 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
|
||||
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
|
||||
let s = setup();
|
||||
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||||
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
|
||||
let mut output = Vec::new();
|
||||
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
|
||||
let replies: Vec<Value> =
|
||||
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let answer = replies.last().unwrap();
|
||||
assert_eq!(answer["id"], 4, "{replies:?}");
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "slept");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
|
||||
struct Many(Vec<PathBuf>);
|
||||
|
||||
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.6";
|
||||
pub const API_VERSION: &str = "1.7";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -170,6 +170,11 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (client, tab, cancel, timeout);
|
||||
Err(HostError::Failed("content tabs unavailable".into()))
|
||||
}
|
||||
/// Opens a diff of two texts in the invoking window.
|
||||
fn open_diff(&self, client: &str, diff: crate::ContentDiff, file_name: Option<String>) -> Result<(), HostError> {
|
||||
let _ = (client, diff, file_name);
|
||||
Err(HostError::Failed("diffs unavailable".into()))
|
||||
}
|
||||
/// Answers a content action in the invoking window.
|
||||
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
|
||||
let _ = (client, request, reply);
|
||||
|
||||
@@ -922,6 +922,9 @@ pub struct FileDecoration {
|
||||
pub badge: Option<String>,
|
||||
pub style: String,
|
||||
pub tooltip: Option<String>,
|
||||
/// Text shown after the name (API 1.7 `set-file-note`).
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -1770,6 +1770,12 @@ impl views::Host for StoreData {
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn open_diff(&mut self, diff: views::ContentDiff, file_name: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.open_diff(self.invocation.client.as_deref().unwrap(), diff.into(), file_name).map_err(to_wit))
|
||||
}
|
||||
fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
@@ -2491,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2501,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
}
|
||||
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
|
||||
}
|
||||
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.6.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.7.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.7.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.6 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
005d9da29eb7d713a97ff872051e408f653030434d837835843298553c9e12e0
|
||||
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6359ef4271f38374889412bd926d9aa735f59a8ce047ffcb06c47976444196cf
|
||||
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6f560f3d0d7737150d2dfa09c3b801bd3b4c3a52cef9893ed8fc045809fe33e1
|
||||
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4024af2d55d7fe4e8c456efd7e0c497ade4b2c5959e9108ff218f4a669e920f4
|
||||
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4ca977f53a18e9e8f869d34e027a15abece9e97b9331e23d4f71af721b104611
|
||||
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3d12a00f81e0db1b0902c2ddebfd6454a056f15b97add80b22a0333722568dc8
|
||||
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
95d89e411216f50d0b34bee110c6bfc9b8fbcb2ffaae2e912c3609f7bf678b2c
|
||||
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
|
||||
@@ -40,6 +40,10 @@ title = "write"
|
||||
id = "content"
|
||||
title = "content"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "diff"
|
||||
title = "diff"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "events"
|
||||
title = "events"
|
||||
|
||||
@@ -78,6 +78,18 @@ impl Plugin for Execution {
|
||||
})?;
|
||||
Ok(None)
|
||||
}
|
||||
"diff" => {
|
||||
host::open_diff(
|
||||
&ContentDiff {
|
||||
left: "before\n".into(),
|
||||
right: "after\n".into(),
|
||||
title: Some("Review".into()),
|
||||
..Default::default()
|
||||
},
|
||||
Some("src/review.rs"),
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"events" => host::storage::get("events"),
|
||||
"validation" => host::storage::get("validation"),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
dd4b6c5153892a4f3facb3f23cb1d92da39a3977985b8abf4eaa9558decfa4a9
|
||||
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
325bc15cd28933d9687bc3c316008f921f48a4a69d7528c6b367fa0ff22489b8
|
||||
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
abe706392e04ef5a14f9feab88e0c0d6a9e96bee7de771b61deeb65e7d9e0a7c
|
||||
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
fb3677946021b30d226e2e3d422c352645c2065c93057c0a645246520860fc90
|
||||
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a01852ead9004ac1558028da19a1ae96ccd035775484089fd39f14263082daf1
|
||||
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bf00050b7bb9911680a94fd36aaeb83fc96d94030e8ceef51d1723c3950253f9
|
||||
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d00a07d9d51e7ca117cec59070d2812d4d1efb2730b60a6f171c8fd12903d055
|
||||
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0aa0ee56e8bacd4d218bafecdec7f4b6249381fad6e31f8b2ee89fd88b16d710
|
||||
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e06955818969808e6695fba9178727239e343bc4542efbf56262dd407175fd8a
|
||||
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7af67673471d49f97aa2cc131b5deddfc78ee7fc203901eba12f4970a5b13f75
|
||||
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e81d2454a61977ab69dc8b5c3c617627c559c4e340337a3d43309daeb9b1c12d
|
||||
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
|
||||
+3
-3
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.6.0;
|
||||
export openidea:plugin/providers@1.6.0;
|
||||
export openidea:plugin/file-actions@1.6.0;
|
||||
include openidea:plugin/plugin-syntax@1.7.0;
|
||||
export openidea:plugin/providers@1.7.0;
|
||||
export openidea:plugin/file-actions@1.7.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
123ae68df6baa3b5983349ba05fb441ec67fddcb32cab566b0d02e443bb4800e
|
||||
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
7b26a4ec36b496f9a4f5e029ef557707961a281e31508863c989a5a5abd5e225
|
||||
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
67f278ade12d11b1445ac3d448853ec46282f2f3e2b2299c6c8fe0dfb4fc86cd
|
||||
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8cf9210844a1968f485e6e747f6dff4a1fdf0a628e2945f731d8289dd01f8385
|
||||
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
|
||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user