mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae14a61eec |
No files matched your search
@@ -151,13 +151,6 @@ jobs:
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||
pnpm exec tauri signer sign --app-version $version $asset
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
gh release upload $env:TAG "$asset.sig" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
@@ -240,8 +233,6 @@ jobs:
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
|
||||
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
|
||||
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -380,14 +373,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -8,18 +8,13 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
/// The `encoding_rs` encoding of a Windows code page, when it has one.
|
||||
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -102,56 +101,6 @@ pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding>
|
||||
})
|
||||
}
|
||||
|
||||
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
|
||||
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
|
||||
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
|
||||
pub fn console_code_page() -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
|
||||
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
|
||||
(cp != 65001).then_some(cp)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
|
||||
/// piece by piece.
|
||||
#[cfg(windows)]
|
||||
pub fn is_single_byte_code_page(cp: u32) -> bool {
|
||||
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
|
||||
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
|
||||
unsafe {
|
||||
let mut info: CPINFO = std::mem::zeroed();
|
||||
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
|
||||
/// like the OEM pages 437, 850 or 852).
|
||||
#[cfg(windows)]
|
||||
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
|
||||
use windows_sys::Win32::Globalization::MultiByteToWideChar;
|
||||
let Ok(len) = i32::try_from(bytes.len()) else {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
};
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
|
||||
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
|
||||
if needed <= 0 {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut wide = vec![0u16; needed as usize];
|
||||
// SAFETY: `wide` holds `needed` units.
|
||||
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
|
||||
String::from_utf16_lossy(&wide[..written.max(0) as usize])
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
@@ -296,16 +296,6 @@ pub struct McpServerSettings {
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
}
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
|
||||
@@ -60,8 +60,6 @@ pub struct Host {
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
@@ -143,7 +141,6 @@ impl Host {
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
@@ -272,7 +269,6 @@ impl Host {
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.mcp_agent_runs.forget_window(window);
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
|
||||
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
|
||||
|
||||
use super::editors::problem;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult, Severity};
|
||||
use ide_lsp::DocumentCheck;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 30_000;
|
||||
const MAX_FILES: usize = 20;
|
||||
|
||||
pub fn check_files() -> Tool {
|
||||
Tool::new(
|
||||
"check_files",
|
||||
&format!(
|
||||
"Checks files with the language servers now and returns their problems: call it after changing files \
|
||||
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
|
||||
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
|
||||
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
|
||||
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
|
||||
|
||||
Per file, `status` says what the problems are worth:
|
||||
- checked: every server answered; `problems` is complete (empty means no problems).
|
||||
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
|
||||
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
|
||||
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
|
||||
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
|
||||
- noLanguageServer: no language server is configured for this kind of file.
|
||||
- untrusted: language servers don't run in an untrusted project.
|
||||
- failed: the file could not be opened (`error`).
|
||||
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
|
||||
the file on disk. Lines and UTF-16 columns are 1-based."
|
||||
),
|
||||
|call| async move {
|
||||
let raw = call.str_list("filePaths")?;
|
||||
if raw.is_empty() {
|
||||
return Err(IdeError::invalid("filePaths must name at least one file"));
|
||||
}
|
||||
if raw.len() > MAX_FILES {
|
||||
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
|
||||
}
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let mut paths = Vec::new();
|
||||
for p in raw {
|
||||
paths.push(call.resolve_readable(&p).await?);
|
||||
}
|
||||
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
|
||||
let files = futures::future::join_all(checks).await;
|
||||
Output::json(json!({ "files": files }))
|
||||
},
|
||||
)
|
||||
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
|
||||
.param(Param::optional(
|
||||
"errorsOnly",
|
||||
ParamType::Boolean,
|
||||
"Whether to return only errors (default) or warnings and hints too",
|
||||
))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
|
||||
}
|
||||
|
||||
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
|
||||
let mut out = json!({ "filePath": call.display(&path) });
|
||||
if !call.project().is_trusted() {
|
||||
out["status"] = "untrusted".into();
|
||||
return out;
|
||||
}
|
||||
match check(call, &path, limit).await {
|
||||
Ok((check, dirty, text)) => {
|
||||
let servers: Vec<Value> = check
|
||||
.servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
|
||||
if let Some(m) = &s.message {
|
||||
v["message"] = m.clone().into();
|
||||
}
|
||||
if s.incomplete {
|
||||
v["incomplete"] = true.into();
|
||||
}
|
||||
if let Some(e) = &s.error {
|
||||
v["error"] = e.clone().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
out["status"] = file_status(&check).into();
|
||||
out["problems"] = check
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.filter(|d| !errors_only || d.severity == Severity::Error)
|
||||
.map(|d| problem(d, &text))
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
out["servers"] = servers.into();
|
||||
if dirty {
|
||||
out["unsavedInEditor"] = true.into();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
out["status"] = "failed".into();
|
||||
out["error"] = e.message.into();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn file_status(check: &DocumentCheck) -> &'static str {
|
||||
if check.servers.is_empty() {
|
||||
"noLanguageServer"
|
||||
} else if !check.servers.iter().any(|s| s.checked) {
|
||||
"serverUnavailable"
|
||||
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
|
||||
"checked"
|
||||
} else {
|
||||
"incomplete"
|
||||
}
|
||||
}
|
||||
|
||||
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
|
||||
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
|
||||
let project = call.project().clone();
|
||||
let docs = project.documents.clone();
|
||||
let p = path.to_owned();
|
||||
let at = Instant::now();
|
||||
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
|
||||
let (doc, changed, dirty) = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
|
||||
docs.on_disk_changed(std::slice::from_ref(&p));
|
||||
let doc = docs.open(&p)?.id;
|
||||
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
|
||||
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
|
||||
})
|
||||
.await?;
|
||||
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
|
||||
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
|
||||
let docs = project.documents.clone();
|
||||
let _ = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
docs.close(doc);
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
Ok((result?, dirty, text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_lsp::CheckedServer;
|
||||
|
||||
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
|
||||
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_say_whether_the_problems_are_complete() {
|
||||
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
|
||||
assert_eq!(check(vec![]), "noLanguageServer");
|
||||
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
|
||||
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
|
||||
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
|
||||
assert_eq!(
|
||||
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
|
||||
"incomplete"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
json!({
|
||||
"severity": d.severity, "description": d.message,
|
||||
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
|
||||
|
||||
@@ -30,14 +30,9 @@ fn read_file() -> Tool {
|
||||
"Reads a file in the project directory or from any project dependency or other project source root.
|
||||
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
|
||||
Returns numbered lines (1-indexed) as text.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
|
||||
lines with a `…N lines truncated…` marker between them.
|
||||
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
|
||||
followed by `…N more lines; read on with offset=M…` when the file goes on.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file.
|
||||
The maximum accepted limit is 5000 lines.",
|
||||
|call| async move {
|
||||
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
|
||||
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
|
||||
let offset = positive(&call, "offset", 1)?;
|
||||
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
|
||||
if limit > MAX_READ_LIMIT {
|
||||
@@ -55,7 +50,7 @@ The maximum accepted limit is 5000 lines.",
|
||||
)));
|
||||
}
|
||||
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
|
||||
},
|
||||
)
|
||||
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
|
||||
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
|
||||
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
|
||||
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
|
||||
/// marker between them.
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
|
||||
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
|
||||
/// Lines are the editor's: a trailing line break starts an empty last line.
|
||||
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
|
||||
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
let count = lines.len();
|
||||
if start > count {
|
||||
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
|
||||
if available <= max_lines {
|
||||
return Ok(range(start, count));
|
||||
}
|
||||
if window {
|
||||
let end = start + max_lines - 1;
|
||||
return Ok(format!(
|
||||
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
|
||||
range(start, end),
|
||||
count - end,
|
||||
end + 1
|
||||
));
|
||||
}
|
||||
let head = max_lines / 2;
|
||||
let tail = max_lines - head;
|
||||
let head_end = start + head - 1;
|
||||
@@ -261,7 +245,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slice(text: &str, start: usize, max: usize) -> String {
|
||||
render_slice(text, start, max, false).unwrap()
|
||||
render_slice(text, start, max).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -270,27 +254,18 @@ mod tests {
|
||||
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
|
||||
assert_eq!(slice("", 1, 10), "L1: ");
|
||||
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
|
||||
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_head_and_tail_of_a_long_default_read() {
|
||||
fn keeps_head_and_tail_of_long_slices() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
let window = |start, max| render_slice(&text, start, max, true).unwrap();
|
||||
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
|
||||
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
|
||||
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_long_lines_at_utf16_units() {
|
||||
let long = "x".repeat(2100);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
|
||||
mod check;
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
pub(crate) mod run;
|
||||
mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
@@ -17,7 +16,6 @@ use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
|
||||
let mut tools = files::tools();
|
||||
tools.extend(search::tools());
|
||||
tools.extend(editors::tools());
|
||||
tools.push(check::check_files());
|
||||
tools.push(symbols::get_symbol_info());
|
||||
tools.push(symbols::find_references());
|
||||
tools.extend(edit::tools());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,418 +0,0 @@
|
||||
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
|
||||
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUN_TIMEOUT_MS: usize = 10_000;
|
||||
const WAIT_TIMEOUT_MS: usize = 30_000;
|
||||
const STOP_TIMEOUT_MS: usize = 10_000;
|
||||
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
|
||||
const DEFAULT_MAX_LINES: usize = 2000;
|
||||
/// Bytes of the log read for one result at most.
|
||||
const READ_CAP: usize = 1 << 20;
|
||||
/// Lines of output quoted when a name is still running.
|
||||
const BUSY_TAIL_LINES: usize = 20;
|
||||
|
||||
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
|
||||
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
|
||||
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
|
||||
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
|
||||
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
|
||||
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
|
||||
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
|
||||
wait for their confirmation before you continue.";
|
||||
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
|
||||
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
|
||||
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
|
||||
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
|
||||
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
call.host()
|
||||
.mcp_agent_runs
|
||||
.get(call.root(), &name)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
|
||||
}
|
||||
|
||||
/// `status` of `run`, with interventions not told yet.
|
||||
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
|
||||
let mut out = run.status();
|
||||
out.extend(extra);
|
||||
let interventions = run.take_interventions();
|
||||
if !interventions.is_empty() {
|
||||
out.insert("interventions".into(), interventions.into());
|
||||
out.insert("advice".into(), INTERVENTION_ADVICE.into());
|
||||
}
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
|
||||
/// as `truncateMode` says.
|
||||
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
|
||||
let max_lines = match call.opt_i64("maxLinesCount")? {
|
||||
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
|
||||
Some(n) => n as usize,
|
||||
None => DEFAULT_MAX_LINES,
|
||||
};
|
||||
let mode = match call.opt_str("truncateMode")? {
|
||||
Some(name) => TruncateMode::parse(&name)
|
||||
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
|
||||
None => TruncateMode::Start,
|
||||
};
|
||||
let len = run.log_len();
|
||||
let from = from.min(len);
|
||||
// A long output is read from the end that stays.
|
||||
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
|
||||
let (raw, start, end) = run.read(start, READ_CAP)?;
|
||||
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
|
||||
let mut out = Map::new();
|
||||
out.insert("output".into(), text.into());
|
||||
if truncated > 0 {
|
||||
out.insert("truncatedLines".into(), truncated.into());
|
||||
}
|
||||
out.insert("outputStart".into(), start.into());
|
||||
out.insert("nextOffset".into(), end.into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
|
||||
Ok(tokio::time::Instant::now() + timeout(call, default)?)
|
||||
}
|
||||
|
||||
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
|
||||
call.opt_str("pattern")?
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
|
||||
match call.opt_i64(name)? {
|
||||
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
|
||||
n => Ok(n.map(|n| n as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
fn busy(run: &AgentRun) -> IdeError {
|
||||
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
|
||||
IdeError::invalid(format!(
|
||||
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
|
||||
run.name,
|
||||
run.command(),
|
||||
clean_output(&tail).trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_command() -> Tool {
|
||||
Tool::new(
|
||||
"run_command",
|
||||
&format!(
|
||||
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
|
||||
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
|
||||
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
|
||||
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
|
||||
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
|
||||
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
|
||||
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
let command = call.str("command")?;
|
||||
if command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("command must not be empty"));
|
||||
}
|
||||
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
|
||||
Some(dir) => {
|
||||
let path = call.resolve_inside(&dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
|
||||
}
|
||||
path
|
||||
}
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
let runs = &host.mcp_agent_runs;
|
||||
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
|
||||
return Err(busy(&run));
|
||||
}
|
||||
let shown_cwd = call.display(&cwd);
|
||||
let mut detail = format!(
|
||||
"{command}\n\nTab: {name}\nWorking directory: {}",
|
||||
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
|
||||
);
|
||||
for (key, value) in env.iter().flatten() {
|
||||
detail.push_str(&format!("\n{key}={value}"));
|
||||
}
|
||||
call.confirm(&name, Some(detail)).await?;
|
||||
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
|
||||
let request = ide_core::event::McpUiRequest::AgentRun {
|
||||
name: name.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.into(),
|
||||
env: env.unwrap_or_default().into_iter().collect(),
|
||||
};
|
||||
if let Err(e) = call.ask(request).await {
|
||||
let error = run.start_error();
|
||||
runs.abandon(&run);
|
||||
return Err(match error {
|
||||
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(error) = run.start_error() {
|
||||
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
|
||||
}
|
||||
let from = run.process_offset();
|
||||
let end = run.wait(deadline, None, from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::required(
|
||||
"command",
|
||||
ParamType::String,
|
||||
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Directory inside the project to run in, relative to the project root; default: the project root",
|
||||
))
|
||||
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
|
||||
.param(Param::optional(
|
||||
"timeout",
|
||||
ParamType::Integer,
|
||||
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
|
||||
keeps running after it",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn wait_for_run() -> Tool {
|
||||
Tool::new(
|
||||
"wait_for_run",
|
||||
&format!(
|
||||
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
|
||||
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
|
||||
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
|
||||
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
|
||||
let pattern = pattern(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
|
||||
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
|
||||
.param(Param::optional(
|
||||
"pattern",
|
||||
ParamType::String,
|
||||
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"offset",
|
||||
ParamType::Integer,
|
||||
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
|
||||
command's output starts",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn read_run_output() -> Tool {
|
||||
Tool::new(
|
||||
"read_run_output",
|
||||
&format!(
|
||||
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
|
||||
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
|
||||
{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or(0);
|
||||
let extra = output_view(&call, &run, from)?;
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn get_run_status() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_status",
|
||||
&format!(
|
||||
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
|
||||
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
|
||||
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
|
||||
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
}
|
||||
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Key {
|
||||
/// Writes this text to its input.
|
||||
Text(&'static str),
|
||||
/// Ctrl+C: stops it, as `stop_run` does.
|
||||
Interrupt,
|
||||
/// Ctrl+D: ends its input.
|
||||
EndOfInput,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> IdeResult<Key> {
|
||||
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
|
||||
"enter" | "return" => Key::Text("\n"),
|
||||
"tab" => Key::Text("\t"),
|
||||
"ctrl+c" => Key::Interrupt,
|
||||
"ctrl+d" => Key::EndOfInput,
|
||||
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
|
||||
Ctrl+C to stop it or Ctrl+D to end its input"
|
||||
)));
|
||||
}
|
||||
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_run_input() -> Tool {
|
||||
Tool::new(
|
||||
"send_run_input",
|
||||
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
|
||||
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
|
||||
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
|
||||
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let text = call.opt_str("text")?.unwrap_or_default();
|
||||
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
|
||||
if text.is_empty() && keys.is_empty() {
|
||||
return Err(IdeError::invalid("pass `text`, `keys` or both"));
|
||||
}
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
call.project().require_trust()?;
|
||||
let host = call.host();
|
||||
let mut data = text;
|
||||
for key in keys {
|
||||
match key {
|
||||
Key::Text(text) => data.push_str(text),
|
||||
Key::EndOfInput | Key::Interrupt => {
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
|
||||
}
|
||||
if key == Key::EndOfInput {
|
||||
crate::ops::run::close_stdin(&host, id)?;
|
||||
} else {
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &data)?;
|
||||
}
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("text", ParamType::String, "Text to type"))
|
||||
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
|
||||
}
|
||||
|
||||
pub fn stop_run() -> Tool {
|
||||
Tool::new(
|
||||
"stop_run",
|
||||
&format!(
|
||||
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
|
||||
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
|
||||
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
|
||||
keeps `stopping`: call again with `force`. The tab stays open with its output."
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
let force = call.flag("force", false)?;
|
||||
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
if force {
|
||||
// A second Stop kills the tree.
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
}
|
||||
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
|
||||
let mut extra = Map::new();
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
|
||||
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
|
||||
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
|
||||
assert!(key("F13").unwrap_err().message.contains("unknown key"));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 60_000;
|
||||
const MAX_TIMEOUT_MS: usize = 3_600_000;
|
||||
const MAX_PROBLEMS: usize = 300;
|
||||
/// Console lines attached to a failure whose messages were not parsed.
|
||||
const CONSOLE_LINES: usize = 200;
|
||||
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
|
||||
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
|
||||
modules containing those files. Asks the user first for every call. Problems have the project-relative \
|
||||
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
|
||||
is an ERROR. On timeout (0 times out at once) the build is stopped.",
|
||||
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|
||||
|call| async move {
|
||||
let files: Vec<String> =
|
||||
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
|
||||
let rebuild = call.flag("rebuild", false)? && files.is_empty();
|
||||
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
|
||||
let mut targets: Vec<Option<PathBuf>> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for f in &files {
|
||||
|
||||
@@ -19,6 +19,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// No limit in JetBrains; the process keeps running after the timeout anyway.
|
||||
const MAX_TIMEOUT_MS: usize = 1_800_000;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
|
||||
)?;
|
||||
let wait = call.flag("waitForExit", true)?;
|
||||
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
|
||||
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let configs = load_configs(&call).await?;
|
||||
let mut config = match &target {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Run configurations, builds and agent commands; every start asks the user first.
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
|
||||
pub(crate) mod agent;
|
||||
mod agent_tools;
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
let mut tools =
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
|
||||
tools.extend(agent_tools::tools());
|
||||
tools
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
}
|
||||
|
||||
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
|
||||
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
|
||||
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
|
||||
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
|
||||
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
|
||||
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
|
||||
Ok(match call.opt_i64("timeout")? {
|
||||
None => Duration::from_millis(default as u64),
|
||||
Some(n) if n <= 0 => Duration::ZERO,
|
||||
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
})
|
||||
}
|
||||
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
|
||||
/// runs from the workspace root), else its working directory.
|
||||
fn scope_of(root: &Path, c: &RunConfig) -> String {
|
||||
match c.extras.module.as_deref() {
|
||||
Some(module) if c.kind == "cargo" => {
|
||||
let module = module.replace('\\', "/");
|
||||
let module = module.strip_prefix("./").unwrap_or(&module);
|
||||
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
_ => cwd_of(root, c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
|
||||
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
|
||||
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
|
||||
let score = |c: &RunConfig| -> Option<usize> {
|
||||
let cwd = scope_of(root, c);
|
||||
let cwd = cwd_of(root, c);
|
||||
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
|
||||
};
|
||||
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
|
||||
@@ -935,33 +922,6 @@ mod tests {
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_member_runs_its_own_tests() {
|
||||
let root = Path::new("/p");
|
||||
let member = |pkg: &str, dir: &str| {
|
||||
let mut c = blank(
|
||||
format!("cargo:test:{pkg}"),
|
||||
"test",
|
||||
"cargo",
|
||||
"cargo".into(),
|
||||
vec!["test".into(), "-p".into(), pkg.into()],
|
||||
);
|
||||
c.is_test = true;
|
||||
c.extras.module = Some(dir.into());
|
||||
c
|
||||
};
|
||||
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
|
||||
workspace.is_test = true;
|
||||
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
|
||||
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
|
||||
let rel = "crates/ide-mcp/src/stdio.rs";
|
||||
let ts = detect(src, rel);
|
||||
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
|
||||
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_args_get_one_separator() {
|
||||
let root = Path::new("/p");
|
||||
|
||||
@@ -38,14 +38,6 @@ const LINE_WINDOW: usize = 500;
|
||||
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
|
||||
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
|
||||
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
|
||||
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
|
||||
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
|
||||
each side. At most 50. Default: the matched line only.";
|
||||
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
|
||||
/// on each side of it; a longer context line to its start.
|
||||
const MAX_LINE_TEXT: usize = 300;
|
||||
const SNIPPET_AROUND: usize = 100;
|
||||
const MAX_CONTEXT_LINES: u32 = 50;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![search_text(), search_regex(), search_symbol(), search_file()]
|
||||
@@ -57,8 +49,6 @@ fn search_text() -> Tool {
|
||||
"Searches for a text substring within project files.
|
||||
Use this tool for fast text search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
@@ -67,7 +57,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Text to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
|
||||
"Searches for regex matches within project files.
|
||||
Use this tool when you need regex search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
|
||||
with …), and with `context` the lines around it.
|
||||
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
@@ -88,7 +75,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
|
||||
"Searches for symbols (classes, methods, fields).
|
||||
Use this tool for semantic lookup by identifier fragments.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
Project results have their line (`lineText`), and with `context` the lines around it.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
By default this searches project symbols only.
|
||||
@@ -114,7 +99,6 @@ If you don't find a suitable result, try again with include_external=true to sea
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -161,13 +145,6 @@ struct SearchItem {
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
/// The text of `start_line` (cut when long).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
line_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_before: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
context_after: Vec<String>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
|
||||
"name": string, "container": string, "lineText": string,
|
||||
"contextBefore": { "type": "array", "items": string },
|
||||
"contextAfter": { "type": "array", "items": string },
|
||||
"name": string, "container": string,
|
||||
},
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
// --- matched lines -------------------------------------------------------------
|
||||
|
||||
/// The `context` argument as (lines before, lines after).
|
||||
fn context(call: &Call) -> IdeResult<(u32, u32)> {
|
||||
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
|
||||
let (sign, n) = match call.args.get("context") {
|
||||
None | Some(Value::Null) => return Ok((0, 0)),
|
||||
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
|
||||
Some(Value::String(s)) => {
|
||||
let s = s.trim();
|
||||
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
|
||||
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
|
||||
None => (None, s.parse().map_err(|_| invalid())?),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(invalid()),
|
||||
};
|
||||
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
|
||||
Ok(match sign {
|
||||
Some('+') => (0, n),
|
||||
Some(_) => (n, 0),
|
||||
None => (n, n),
|
||||
})
|
||||
}
|
||||
|
||||
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
|
||||
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
|
||||
fn snippet(line: &str, start: usize, end: usize) -> String {
|
||||
let units: usize = line.chars().map(char::len_utf16).sum();
|
||||
if units <= MAX_LINE_TEXT {
|
||||
return line.to_owned();
|
||||
}
|
||||
let from = start.saturating_sub(SNIPPET_AROUND);
|
||||
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
|
||||
let (mut out, mut at) = (String::new(), 0);
|
||||
for c in line.chars() {
|
||||
if at >= from && at < to {
|
||||
out.push(c);
|
||||
}
|
||||
at += c.len_utf16();
|
||||
}
|
||||
let before = if from > 0 { "\u{2026}" } else { "" };
|
||||
let after = if to < units { "\u{2026}" } else { "" };
|
||||
format!("{before}{out}{after}")
|
||||
}
|
||||
|
||||
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
|
||||
fn context_line(line: &str) -> String {
|
||||
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
|
||||
}
|
||||
|
||||
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
|
||||
/// as they are); each file is read once, with unsaved editor text.
|
||||
async fn fill_lines(
|
||||
call: &Call,
|
||||
items: &mut [SearchItem],
|
||||
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
|
||||
(before, after): (u32, u32),
|
||||
) {
|
||||
let host = call.host();
|
||||
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
|
||||
for item in items {
|
||||
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
|
||||
if current.as_ref().is_none_or(|(p, _)| p != &path) {
|
||||
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
|
||||
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
|
||||
current = Some((path, lines));
|
||||
}
|
||||
let Some((_, Some(lines))) = ¤t else { continue };
|
||||
let index = line as usize - 1;
|
||||
let Some(text) = lines.get(index) else { continue };
|
||||
let start = item.start_column.unwrap_or(1) as usize - 1;
|
||||
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
|
||||
item.line_text = Some(snippet(text, start, end));
|
||||
let first = index.saturating_sub(before as usize);
|
||||
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
|
||||
let last = (index + after as usize).min(lines.len() - 1);
|
||||
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// --- path globs ----------------------------------------------------------------
|
||||
|
||||
/// The `paths` argument: project-relative globs, `!` excludes.
|
||||
@@ -522,7 +416,6 @@ struct TextHit {
|
||||
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
// Globs Find in Files cannot take are applied afterwards, so the engine must not
|
||||
// stop at `limit` matches the filter may drop.
|
||||
@@ -601,7 +494,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let more = hits.len() > limit || summary.limit_hit || timed_out;
|
||||
hits.truncate(limit);
|
||||
extend_spanning(&call, &query, &mut hits, spanning).await;
|
||||
let mut items: Vec<SearchItem> = hits
|
||||
let items = hits
|
||||
.into_iter()
|
||||
.map(|h| SearchItem {
|
||||
file_path: h.file_path,
|
||||
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let absolute: std::collections::HashMap<&str, &Path> =
|
||||
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
|
||||
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
@@ -836,7 +726,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
let started = Instant::now();
|
||||
let q = query(&call, "Search query is empty")?;
|
||||
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
let around = context(&call)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let external = call.flag("include_external", false)?;
|
||||
let host = call.host();
|
||||
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
|
||||
let root = call.root().to_path_buf();
|
||||
let absolute = |item: &SearchItem| {
|
||||
let path = Path::new(&item.file_path);
|
||||
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
|
||||
};
|
||||
fill_lines(&call, &mut items, absolute, around).await;
|
||||
let indexing = indexing_before || project.index.status().indexing;
|
||||
let partial_result_reason = if indexing {
|
||||
Some(INDEXING_PARTIAL_RESULT_REASON)
|
||||
@@ -987,17 +869,6 @@ mod tests {
|
||||
assert_eq!(byte_at("ab", 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lines_are_cut_around_the_match() {
|
||||
assert_eq!(snippet("short line", 0, 5), "short line");
|
||||
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
|
||||
let cut = snippet(&line, 400, 406);
|
||||
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
|
||||
let start = snippet(&line, 0, 0);
|
||||
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
|
||||
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_scopes() {
|
||||
let (_dir, root) = root();
|
||||
|
||||
@@ -313,11 +313,10 @@ pub async fn build_sync(
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
let p = project.clone();
|
||||
tokio::task::spawn_blocking(move || p.refresh_repositories());
|
||||
}
|
||||
// New source folders and dependency versions: the Project view and External Libraries reload.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
if tool == BuildTool::Maven
|
||||
&& model.maven.is_some()
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
|
||||
@@ -234,19 +234,7 @@ pub async fn start_request(
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let mut config = config;
|
||||
let agent = match host.project(window) {
|
||||
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
|
||||
Err(_) => None,
|
||||
};
|
||||
let on_output = match &agent {
|
||||
Some(agent) => agent.wrap_output(on_output),
|
||||
None => on_output,
|
||||
};
|
||||
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
|
||||
if let Some(agent) = &agent {
|
||||
agent.started(&result);
|
||||
}
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
@@ -261,7 +249,6 @@ async fn start_inner(
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
agent: Option<&crate::mcp::run::agent::Launch>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -295,9 +282,6 @@ async fn start_inner(
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
sink = agent.wrap_sink(sink);
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
host.mcp_agent_runs.stopping(id);
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
/// Input the user typed into a run (the agent's goes to the process directly).
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
host.mcp_agent_runs.user_input(id, data);
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
|
||||
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
|
||||
assert_eq!(found["more"], true);
|
||||
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
|
||||
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
|
||||
// The matched line comes with the result (unsaved text included), and lines around it on request.
|
||||
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
|
||||
assert!(all["items"][0].get("contextAfter").is_none());
|
||||
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
|
||||
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
|
||||
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
|
||||
assert_eq!(before["items"][0]["lineText"], "needle needle");
|
||||
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
|
||||
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
|
||||
assert!(bad.contains("context must be"), "{bad}");
|
||||
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
|
||||
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
|
||||
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
|
||||
@@ -463,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
|
||||
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
|
||||
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn check_files_says_why_a_file_could_not_be_checked() {
|
||||
let h = Harness::new();
|
||||
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
|
||||
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
|
||||
h.trust().await;
|
||||
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
|
||||
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
|
||||
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
|
||||
assert_eq!(files["files"][0]["problems"], json!([]));
|
||||
assert_eq!(files["files"][1]["status"], "failed", "{files}");
|
||||
assert!(files["files"][1]["error"].is_string());
|
||||
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
|
||||
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
|
||||
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
|
||||
let none = h.error("check_files", json!({"filePaths": []})).await;
|
||||
assert!(none.contains("at least one file"), "{none}");
|
||||
}
|
||||
|
||||
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
|
||||
/// agent tab's configuration (shell from the default) and says whether it started. Returns
|
||||
/// the configurations it started, for reruns.
|
||||
#[cfg(unix)]
|
||||
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
|
||||
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut handled = std::collections::HashSet::new();
|
||||
loop {
|
||||
let request = events.lock().unwrap().iter().find_map(|e| match e {
|
||||
IdeEvent::McpRequest {
|
||||
id,
|
||||
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
|
||||
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((id, name, command, cwd, env)) = request else {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
continue;
|
||||
};
|
||||
handled.insert(id);
|
||||
let mut config = ide_run::RunConfig {
|
||||
id: format!("mcp-agent:{name}"),
|
||||
name: name.clone(),
|
||||
kind: "shell".into(),
|
||||
command,
|
||||
args: Vec::new(),
|
||||
cwd: Some(cwd),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: true,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
|
||||
config.extras.target = Some(String::new());
|
||||
out.lock().unwrap().push(config.clone());
|
||||
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
|
||||
let error = result.err().map(|e| e.message);
|
||||
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
|
||||
}
|
||||
});
|
||||
started
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
|
||||
let id = format!("mcp-agent:{name}");
|
||||
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
|
||||
let h = Harness::new();
|
||||
h.trust().await;
|
||||
h.host.mcp_requests.set_ready("main", true);
|
||||
h.host
|
||||
.global_settings
|
||||
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
|
||||
.unwrap();
|
||||
let started = run_tool_window(&h);
|
||||
|
||||
// A command that finishes within the wait, in a tab named with the prefix.
|
||||
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
|
||||
assert_eq!(done["name"], "_agent_check", "{done}");
|
||||
assert_eq!(done["state"], "failed", "{done}");
|
||||
assert_eq!(done["exitCode"], 3);
|
||||
assert_eq!(done["startedBy"], "agent");
|
||||
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
|
||||
assert!(done.get("interventions").is_none());
|
||||
|
||||
// The finished name runs the next command; a running one is refused, never stopped.
|
||||
let running =
|
||||
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
|
||||
assert_eq!(running["state"], "running", "{running}");
|
||||
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
|
||||
assert_eq!(matched["waitEnded"], "matched", "{matched}");
|
||||
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
|
||||
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
|
||||
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
|
||||
|
||||
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
|
||||
let process = agent_process(&h, "_agent_check");
|
||||
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
|
||||
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
|
||||
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
|
||||
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
|
||||
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
|
||||
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
|
||||
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
|
||||
|
||||
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
|
||||
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
|
||||
assert!(stopped.get("interventions").is_none());
|
||||
|
||||
// The user's Stop, rerun and closing the tab are reported with their time.
|
||||
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
|
||||
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
|
||||
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
|
||||
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
|
||||
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
|
||||
|
||||
let config = started.lock().unwrap().last().cloned().unwrap();
|
||||
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
|
||||
let status = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert_eq!(status["startedBy"], "user", "{status}");
|
||||
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
|
||||
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
|
||||
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
|
||||
let closed = h.json("get_run_status", json!({"name": "server"})).await;
|
||||
assert!(closed["tabClosedAt"].is_string(), "{closed}");
|
||||
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
|
||||
|
||||
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
|
||||
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
|
||||
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
|
||||
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
|
||||
let runs = h.json("list_runs", json!({})).await;
|
||||
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
|
||||
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
|
||||
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
|
||||
|
||||
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
|
||||
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
|
||||
let lines =
|
||||
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
|
||||
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
|
||||
assert_eq!(lines["truncatedLines"], 1);
|
||||
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
|
||||
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
|
||||
|
||||
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
|
||||
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
|
||||
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
|
||||
assert!(key.contains("runs on pipes"), "{key}");
|
||||
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
|
||||
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
|
||||
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
|
||||
}
|
||||
@@ -22,9 +22,6 @@ use std::time::{Duration, Instant};
|
||||
/// How long Inspect Code waits for a server that is still starting (jdtls,
|
||||
/// rust-analyzer on a large workspace).
|
||||
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
|
||||
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
|
||||
/// syntax first, semantics later).
|
||||
const CHECK_SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct InspectOptions {
|
||||
@@ -51,29 +48,6 @@ pub trait InspectHost: Send + Sync + 'static {
|
||||
fn close(&self, doc: DocId);
|
||||
}
|
||||
|
||||
/// One language server's part in a [`DocumentCheck`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CheckedServer {
|
||||
pub name: String,
|
||||
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
|
||||
pub status: ide_core::event::ServerStatus,
|
||||
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
|
||||
pub message: Option<String>,
|
||||
/// Its diagnostics are in the result.
|
||||
pub checked: bool,
|
||||
/// It was still analysing (or had not published for this change) when the wait ended.
|
||||
pub incomplete: bool,
|
||||
/// Asking it failed.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of a document, with every language server that serves it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DocumentCheck {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub servers: Vec<CheckedServer>,
|
||||
}
|
||||
|
||||
/// What became of one file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FileInspection {
|
||||
@@ -224,96 +198,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
|
||||
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
|
||||
/// servers the document was saved, since checks that run on save (rust-analyzer's
|
||||
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
|
||||
/// to be ready, to answer for this text and to finish the background work it started, up
|
||||
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
|
||||
/// problems" from "nothing could check".
|
||||
pub async fn check_document(
|
||||
&self,
|
||||
doc: DocId,
|
||||
changed: Option<Instant>,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<DocumentCheck> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let ready = tokio::select! {
|
||||
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
// Once the servers have the document: a save sent while one starts would not reach it.
|
||||
self.did_save(doc, snap.text.as_str());
|
||||
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
|
||||
let mut report = CheckedServer {
|
||||
name: server.name.clone(),
|
||||
status: server.status(),
|
||||
message: server.status_message(),
|
||||
checked: false,
|
||||
incomplete: false,
|
||||
error: None,
|
||||
};
|
||||
let Some(run) = run else { return Some((report, Vec::new())) };
|
||||
let t = Target { server: server.clone(), run };
|
||||
if !t.caps().restriction.diagnostics() {
|
||||
return None;
|
||||
}
|
||||
let pull = t.caps().has("diagnosticProvider");
|
||||
let mut since = changed;
|
||||
let raw = loop {
|
||||
// A pull asks for this text; a push server is waited on to publish for it.
|
||||
let raw = if pull {
|
||||
server.wait_idle(left(), cancel).await;
|
||||
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
|
||||
} else {
|
||||
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
|
||||
wait_for_publish(server, &snap.path, since, opts, cancel).await
|
||||
};
|
||||
// The save may have started work (a `cargo check`) that publishes when it ends.
|
||||
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
|
||||
break raw;
|
||||
}
|
||||
if !server.wait_idle(left(), cancel).await {
|
||||
break raw.map(|_| server.diagnostics_for(&snap.path));
|
||||
}
|
||||
// Idle: its last publish is final once quiet.
|
||||
since = None;
|
||||
};
|
||||
report.status = server.status();
|
||||
match raw {
|
||||
Ok(items) => {
|
||||
report.checked = true;
|
||||
let fresh =
|
||||
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
|
||||
report.incomplete = !fresh || server.is_busy();
|
||||
let conv = self.conv(t.enc());
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
|
||||
.collect::<Vec<_>>();
|
||||
Some((report, items))
|
||||
}
|
||||
Err(e) => {
|
||||
report.error = Some(e.message);
|
||||
Some((report, Vec::new()))
|
||||
}
|
||||
}
|
||||
});
|
||||
let results = tokio::select! {
|
||||
r = futures::future::join_all(per_server) => r,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let mut out = DocumentCheck::default();
|
||||
for (report, items) in results.into_iter().flatten() {
|
||||
out.servers.push(report);
|
||||
out.diagnostics.extend(items);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn pull_diagnostics(
|
||||
&self,
|
||||
t: &Target,
|
||||
|
||||
@@ -40,7 +40,7 @@ mod vue_navigation;
|
||||
mod vue_refs;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
|
||||
@@ -658,24 +658,6 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
|
||||
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
|
||||
/// cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let notified = self.phase_notify.notified();
|
||||
if !self.is_busy() || self.running().is_none() {
|
||||
return !self.is_busy();
|
||||
}
|
||||
tokio::select! {
|
||||
() = notified => {}
|
||||
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
|
||||
() = cancel.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transiently opened files ----
|
||||
|
||||
/// Registers a holder of the transient file `path` on instance `generation`;
|
||||
|
||||
@@ -2178,57 +2178,6 @@ async fn missing_server_reports_once() {
|
||||
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let s = h.launcher.state("mock");
|
||||
let (doc, path) = h.open("a.mk", "bad\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
|
||||
let (server, uri) = (s.clone(), uri_of(&path));
|
||||
let publisher = tokio::spawn(async move {
|
||||
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
|
||||
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
|
||||
"severity": 1, "message": "on save" });
|
||||
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
|
||||
});
|
||||
let cancel = CancellationToken::new();
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
|
||||
publisher.await.unwrap();
|
||||
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
|
||||
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
|
||||
|
||||
// A change the server never answered: what it has is reported as not fresh.
|
||||
let changed = Some(std::time::Instant::now());
|
||||
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
|
||||
assert_eq!(stale.diagnostics.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn checking_a_document_reports_servers_that_cannot_check() {
|
||||
let cancel = CancellationToken::new();
|
||||
let h = harness(&[("ghost", json!({}))]);
|
||||
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("ghost", ServerStatus::Missing).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert!(check.diagnostics.is_empty());
|
||||
let s = &check.servers[0];
|
||||
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
|
||||
assert_eq!(s.message.as_deref(), Some("install ghost"));
|
||||
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Misconfigured).await;
|
||||
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
|
||||
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
|
||||
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
|
||||
assert!(!check.servers[0].checked);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn launch_error_fails_without_starting_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
+22
-249
@@ -3,39 +3,17 @@
|
||||
use crate::discovery::Discovery;
|
||||
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Either, Full, Limited};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::{JoinError, JoinHandle};
|
||||
use tokio_util::sync::DropGuard;
|
||||
|
||||
/// Largest request body.
|
||||
const MAX_BODY: usize = 8 << 20;
|
||||
/// A call still running after this long is answered on an event stream (when the client
|
||||
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
|
||||
/// response has not started (Claude Code after a minute).
|
||||
#[cfg(not(test))]
|
||||
const STREAM_AFTER: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STREAM_AFTER: Duration = Duration::from_millis(100);
|
||||
/// How often a streamed call reports that it is still running: progress notifications reset
|
||||
/// the client's idle timeout (Claude Code's is five minutes).
|
||||
#[cfg(not(test))]
|
||||
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
#[cfg(test)]
|
||||
const HEARTBEAT: Duration = Duration::from_millis(100);
|
||||
|
||||
type Handled = Result<Option<Value>, Value>;
|
||||
type Body = Either<Full<Bytes>, EventStream>;
|
||||
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
@@ -162,11 +140,15 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
|
||||
async fn serve(
|
||||
req: Request<Incoming>,
|
||||
shared: Arc<Shared>,
|
||||
scope: String,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
Ok(respond(req, &shared, scope).await)
|
||||
}
|
||||
|
||||
type Reply = Response<Body>;
|
||||
type Reply = Response<Full<Bytes>>;
|
||||
|
||||
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
|
||||
if req.uri().path() != "/mcp" {
|
||||
@@ -181,7 +163,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
return response;
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
// No stream of server-initiated messages: every answer is in the POST's response.
|
||||
// No server-sent event streams: every answer is in the POST's response.
|
||||
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
|
||||
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||
return response;
|
||||
@@ -193,41 +175,25 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
|
||||
let project = project_of(&req);
|
||||
let streams =
|
||||
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
|
||||
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
|
||||
};
|
||||
let message = serde_json::from_slice::<Value>(&body).ok();
|
||||
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
|
||||
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
|
||||
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let response_scope = scope.clone();
|
||||
let registry = shared.registry.clone();
|
||||
let shared = shared.clone();
|
||||
let cancel = shared.cancel.child_token();
|
||||
// Cancels the call when the client goes away (the response is dropped).
|
||||
let guard = cancel.clone().drop_guard();
|
||||
let mut handled = tokio::task::spawn_blocking(move || {
|
||||
let _guard = cancel.clone().drop_guard();
|
||||
let handled = tokio::task::spawn_blocking(move || {
|
||||
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
|
||||
});
|
||||
if streams && !initializing {
|
||||
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
|
||||
Ok(done) => return answer(done, false, &response_scope),
|
||||
Err(_) => return event_stream(handled, id, progress_token, guard),
|
||||
}
|
||||
}
|
||||
let done = handled.await;
|
||||
drop(guard);
|
||||
answer(done, initializing, &response_scope)
|
||||
}
|
||||
|
||||
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
|
||||
})
|
||||
.await;
|
||||
match handled {
|
||||
Ok(Ok(Some(reply))) => {
|
||||
let mut response = json(StatusCode::OK, &reply);
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
|
||||
response.headers_mut().insert("mcp-session-id", value);
|
||||
}
|
||||
response
|
||||
@@ -238,71 +204,6 @@ fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a call that is still running on an event stream: a progress notification every
|
||||
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
|
||||
/// answer. The call is cancelled as soon as the client goes away.
|
||||
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut handled => {
|
||||
let reply = match done {
|
||||
Ok(Ok(Some(reply)) | Err(reply)) => reply,
|
||||
Ok(Ok(None)) => return,
|
||||
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
|
||||
};
|
||||
let _ = tx.send(event(&reply)).await;
|
||||
return;
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
let elapsed = started.elapsed();
|
||||
let still_running = match &token {
|
||||
Some(token) => event(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": token,
|
||||
"progress": elapsed.as_secs_f64(),
|
||||
"message": format!("running for {}s", elapsed.as_secs()),
|
||||
},
|
||||
})),
|
||||
None => Bytes::from_static(b": running\n\n"),
|
||||
};
|
||||
if tx.send(still_running).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut response = Response::new(Either::Right(EventStream(rx)));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
/// One server-sent event carrying a JSON-RPC message.
|
||||
fn event(message: &Value) -> Bytes {
|
||||
Bytes::from(format!("event: message\ndata: {message}\n\n"))
|
||||
}
|
||||
|
||||
/// A `text/event-stream` body, written by [`event_stream`]'s task.
|
||||
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
|
||||
|
||||
impl hyper::body::Body for EventStream {
|
||||
type Data = Bytes;
|
||||
type Error = Infallible;
|
||||
|
||||
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
|
||||
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
@@ -341,22 +242,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, text: &str) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
|
||||
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: &Value) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
|
||||
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
fn empty(status: StatusCode) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
|
||||
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
}
|
||||
@@ -364,134 +265,6 @@ fn empty(status: StatusCode) -> Reply {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
|
||||
struct Slow(Arc<AtomicBool>);
|
||||
|
||||
impl ToolRegistry for Slow {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/project")]
|
||||
}
|
||||
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
_: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, crate::ToolCallError> {
|
||||
if name == "wait" {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
return Err(crate::ToolCallError::Failed("cancelled".into()));
|
||||
}
|
||||
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
Ok("done".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> (McpServer, Arc<AtomicBool>) {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let registry = Arc::new(Slow(cancelled.clone()));
|
||||
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
|
||||
}
|
||||
|
||||
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
|
||||
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
|
||||
if let Some(token) = token {
|
||||
params["_meta"] = json!({ "progressToken": token });
|
||||
}
|
||||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
|
||||
}
|
||||
|
||||
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
|
||||
let body = body.to_string();
|
||||
let port = server.port();
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let request = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
server.token(),
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
/// The whole response, lowercased headers and the body as sent (chunked or not).
|
||||
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
|
||||
let (head, body) = out.split_once("\r\n\r\n").unwrap();
|
||||
(head.to_ascii_lowercase(), body.to_owned())
|
||||
}
|
||||
|
||||
fn events(body: &str) -> Vec<Value> {
|
||||
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
|
||||
}
|
||||
|
||||
const BOTH: &str = "application/json, text/event-stream";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
|
||||
let (server, _) = start();
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
|
||||
assert!(head.contains("content-type: text/event-stream"), "{head}");
|
||||
let messages = events(&body);
|
||||
let (answer, progress) = messages.split_last().unwrap();
|
||||
assert_eq!(answer["id"], 3);
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "done");
|
||||
assert!(progress.len() >= 2, "{body}");
|
||||
for pair in progress.windows(2) {
|
||||
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
|
||||
}
|
||||
for p in progress {
|
||||
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
|
||||
}
|
||||
|
||||
// Without a progress token, comments keep the stream going.
|
||||
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
|
||||
assert!(body.contains(": running"), "{body}");
|
||||
assert_eq!(events(&body).len(), 1, "{body}");
|
||||
|
||||
// A client that takes only JSON waits for it.
|
||||
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
|
||||
let (server, cancelled) = start();
|
||||
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
|
||||
let mut head = Vec::new();
|
||||
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(n > 0, "the stream ended early");
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
|
||||
drop(stream);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !cancelled.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the call was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
|
||||
+14
-106
@@ -35,8 +35,7 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
|
||||
}
|
||||
|
||||
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
|
||||
/// writes what it answers, if anything, one message per line to `output`: the
|
||||
/// answer, and before it the progress notifications of a long call.
|
||||
/// writes its answer, if any, as one line to `output`.
|
||||
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
@@ -45,37 +44,23 @@ where
|
||||
let scope = crate::server::new_token()?;
|
||||
let mut lines = input.lines();
|
||||
let mut pending = tokio::task::JoinSet::new();
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let mut eof = false;
|
||||
loop {
|
||||
if eof && pending.is_empty() && received.is_empty() {
|
||||
break;
|
||||
}
|
||||
let reply = tokio::select! {
|
||||
line = lines.next_line(), if !eof => {
|
||||
let Some(line) = line? else { eof = true; continue };
|
||||
if line.trim().is_empty() { continue; }
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => {
|
||||
let (discovery, project, scope, sent) =
|
||||
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
|
||||
pending.spawn(async move {
|
||||
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
|
||||
let _ = sent.send(reply);
|
||||
}
|
||||
});
|
||||
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
|
||||
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
|
||||
continue;
|
||||
}
|
||||
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
|
||||
}
|
||||
}
|
||||
Some(message) = received.recv() => Some(message),
|
||||
done = pending.join_next(), if !pending.is_empty() => {
|
||||
if let Some(Err(e)) = done {
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
|
||||
else => break,
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.to_string().as_bytes()).await?;
|
||||
@@ -87,24 +72,15 @@ where
|
||||
}
|
||||
|
||||
/// The IDE's answer to `message`, or an error response for each request in
|
||||
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
|
||||
/// with the messages before it.
|
||||
async fn relay(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope, sent).await {
|
||||
Ok(Answer::Streamed { answered: true }) => return None,
|
||||
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
|
||||
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
/// it when the IDE can't be reached.
|
||||
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope).await {
|
||||
Ok((StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(reply) => return Some(reply),
|
||||
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
|
||||
},
|
||||
Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Ok((status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
|
||||
@@ -124,23 +100,9 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Answer {
|
||||
Body(StatusCode, Bytes),
|
||||
/// The messages of an event stream went to the channel; `answered`: one was a response.
|
||||
Streamed {
|
||||
answered: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// POSTs `message` to the server in the discovery file; a message for the
|
||||
/// user when that fails.
|
||||
async fn post(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Result<Answer, String> {
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
@@ -163,60 +125,6 @@ async fn post(
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
let status = response.status();
|
||||
let streamed = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
|
||||
let mut body = response.into_body();
|
||||
if !streamed {
|
||||
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
return Ok(Answer::Body(status, body));
|
||||
}
|
||||
let (mut buffer, mut answered) = (Vec::new(), false);
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
|
||||
let Ok(data) = frame.into_data() else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
for data in take_events(&mut buffer) {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(&data) {
|
||||
answered |= message.get("method").is_none();
|
||||
let _ = sent.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Streamed { answered })
|
||||
}
|
||||
|
||||
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
|
||||
/// the rest. Comments and other fields are skipped.
|
||||
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
|
||||
let block: Vec<u8> = buffer.drain(..end + 2).collect();
|
||||
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
|
||||
let data: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|d| d.strip_prefix(' ').unwrap_or(d))
|
||||
.collect();
|
||||
if !data.is_empty() {
|
||||
events.push(data.join("\n"));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_events;
|
||||
|
||||
#[test]
|
||||
fn events_are_taken_whole_and_comments_skipped() {
|
||||
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
|
||||
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
|
||||
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
|
||||
buffer.extend_from_slice(b":2}\n\n");
|
||||
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
Ok((status, body))
|
||||
}
|
||||
@@ -39,10 +39,6 @@ impl ToolRegistry for Registry {
|
||||
match name {
|
||||
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
|
||||
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
|
||||
"sleep" => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
|
||||
Ok("slept".into())
|
||||
}
|
||||
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
|
||||
}
|
||||
}
|
||||
@@ -256,20 +252,6 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
|
||||
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
|
||||
let s = setup();
|
||||
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||||
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
|
||||
let mut output = Vec::new();
|
||||
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
|
||||
let replies: Vec<Value> =
|
||||
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let answer = replies.last().unwrap();
|
||||
assert_eq!(answer["id"], 4, "{replies:?}");
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "slept");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
|
||||
struct Many(Vec<PathBuf>);
|
||||
|
||||
@@ -498,8 +498,7 @@ impl ProcessManager {
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -630,10 +629,7 @@ impl ProcessManager {
|
||||
#[cfg(unix)]
|
||||
Some(Box::new(wait_readable)),
|
||||
)
|
||||
.and_then(|_| {
|
||||
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
|
||||
});
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -1124,35 +1120,18 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends what `decode` writes to `pending`, through the notice filter when there is one.
|
||||
fn emit(
|
||||
decode: impl FnOnce(&mut String),
|
||||
notice: &mut Option<ide_core::output_encoding::NoticeFilter>,
|
||||
decoded: &mut String,
|
||||
pending: &mut String,
|
||||
) {
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
decode(decoded);
|
||||
filter.filter(decoded, pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => decode(pending),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges both streams in arrival order, batching consecutive output of the same stream.
|
||||
/// `decoders` decode stdout and stderr.
|
||||
fn spawn_pump(
|
||||
rx: mpsc::Receiver<Msg>,
|
||||
on_output: OutputFn,
|
||||
entry: Arc<Entry>,
|
||||
sink: SharedSink,
|
||||
decoders: [OutputDecoder; 2],
|
||||
encoding: Option<String>,
|
||||
notice: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
|
||||
let [mut out_dec, mut err_dec] = decoders;
|
||||
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
|
||||
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
|
||||
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
|
||||
@@ -1161,8 +1140,6 @@ fn spawn_pump(
|
||||
let mut pending = String::new();
|
||||
let mut pending_stream = Stream::Stdout;
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
// Since when a decoder holds part of a line back to tell its encoding.
|
||||
let mut held_since: Option<Instant> = None;
|
||||
let mut status: Option<Exit> = None;
|
||||
let mut drain_deadline: Option<Instant> = None;
|
||||
|
||||
@@ -1177,7 +1154,6 @@ fn spawn_pump(
|
||||
let now = Instant::now();
|
||||
let wait = [
|
||||
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
|
||||
held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
|
||||
drain_deadline.map(|t| t.saturating_duration_since(now)),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1194,8 +1170,14 @@ fn spawn_pump(
|
||||
Stream::Stdout => (&mut out_dec, &mut out_notice),
|
||||
Stream::Stderr => (&mut err_dec, &mut err_notice),
|
||||
};
|
||||
emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending);
|
||||
held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None };
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
dec.decode_into(&bytes, &mut decoded);
|
||||
filter.filter(&decoded, &mut pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => dec.decode_into(&bytes, &mut pending),
|
||||
}
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
@@ -1211,25 +1193,6 @@ fn spawn_pump(
|
||||
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
break;
|
||||
}
|
||||
// Nothing more came: show a held prompt.
|
||||
if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
|
||||
held_since = None;
|
||||
for (stream, dec, notice) in [
|
||||
(Stream::Stdout, &mut out_dec, &mut out_notice),
|
||||
(Stream::Stderr, &mut err_dec, &mut err_notice),
|
||||
] {
|
||||
if dec.holds() {
|
||||
if stream != pending_stream {
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
pending_stream = stream;
|
||||
}
|
||||
emit(|out| dec.release(out), notice, &mut decoded, &mut pending);
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
}
|
||||
|
||||
+1
-390
@@ -136,7 +136,6 @@ mod tests {
|
||||
pub enum OutputDecoder {
|
||||
Utf8(Utf8Decoder),
|
||||
Other(Box<encoding_rs::Decoder>),
|
||||
Mixed(Box<MixedDecoder>),
|
||||
}
|
||||
|
||||
impl OutputDecoder {
|
||||
@@ -148,36 +147,10 @@ impl OutputDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes
|
||||
/// what Windows' own programs write: the lines that are not UTF-8 in the system's console
|
||||
/// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16
|
||||
/// (`wsl.exe`, `cmd /u`).
|
||||
pub fn for_pipes(label: Option<&str>) -> Self {
|
||||
match Self::for_label(label) {
|
||||
Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new(
|
||||
ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page),
|
||||
))),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
|
||||
match self {
|
||||
Self::Utf8(d) => d.decode_into(bytes, out),
|
||||
Self::Other(d) => decode_with(d, bytes, out, false),
|
||||
Self::Mixed(d) => d.decode_into(bytes, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether part of a line is held back until it shows its encoding.
|
||||
pub fn holds(&self) -> bool {
|
||||
matches!(self, Self::Mixed(d) if d.holds())
|
||||
}
|
||||
|
||||
/// Decodes the held part of a line now (no more output came for a while).
|
||||
pub fn release(&mut self, out: &mut String) {
|
||||
if let Self::Mixed(d) = self {
|
||||
d.release(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,218 +162,10 @@ impl OutputDecoder {
|
||||
decode_with(d, &[], &mut out, true);
|
||||
out
|
||||
}
|
||||
Self::Mixed(d) => d.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte
|
||||
/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…).
|
||||
pub enum NativeDecoder {
|
||||
Encoding(Box<encoding_rs::Decoder>),
|
||||
#[cfg(windows)]
|
||||
SingleByte(u32),
|
||||
}
|
||||
|
||||
impl NativeDecoder {
|
||||
/// `None` for UTF-8 and for code pages neither can decode piece by piece.
|
||||
pub fn for_code_page(cp: u32) -> Option<Self> {
|
||||
if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) {
|
||||
return (enc != encoding_rs::UTF_8)
|
||||
.then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling())));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if ide_core::output_encoding::is_single_byte_code_page(cp) {
|
||||
return Some(Self::SingleByte(cp));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) {
|
||||
match self {
|
||||
Self::Encoding(d) => decode_with(d, bytes, out, last),
|
||||
#[cfg(windows)]
|
||||
Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Line {
|
||||
/// Only ASCII so far, which reads the same in both.
|
||||
Open,
|
||||
Utf8,
|
||||
Native,
|
||||
}
|
||||
|
||||
/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo,
|
||||
/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS,
|
||||
/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command.
|
||||
///
|
||||
/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without
|
||||
/// one). The part of a line after its first non-ASCII byte is held until the line ends or
|
||||
/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes.
|
||||
///
|
||||
/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters
|
||||
/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read
|
||||
/// without NUL bytes comes.
|
||||
pub struct MixedDecoder {
|
||||
utf8: Utf8Decoder,
|
||||
native: Option<NativeDecoder>,
|
||||
line: Line,
|
||||
held: Vec<u8>,
|
||||
utf16: Option<Utf16>,
|
||||
}
|
||||
|
||||
impl MixedDecoder {
|
||||
pub fn new(native: Option<NativeDecoder>) -> Self {
|
||||
Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None }
|
||||
}
|
||||
|
||||
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let nul = bytes.contains(&0);
|
||||
match self.utf16.as_ref().map(|u| u.open || nul) {
|
||||
Some(true) => {
|
||||
if let Some(u) = &mut self.utf16 {
|
||||
u.decode(bytes, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(false) => self.utf16 = None,
|
||||
None if nul => {
|
||||
self.end_line(out);
|
||||
let mut u = Utf16::new();
|
||||
u.decode(utf16_aligned(bytes), out);
|
||||
self.utf16 = Some(u);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for piece in bytes.split_inclusive(|b| *b == b'\n') {
|
||||
let ends = piece.last() == Some(&b'\n');
|
||||
match self.line {
|
||||
Line::Utf8 => self.utf8.decode_into(piece, out),
|
||||
Line::Native => self.decode_native(piece, out),
|
||||
Line::Open => {
|
||||
let mut rest = piece;
|
||||
if self.held.is_empty() {
|
||||
let ascii = piece.iter().take_while(|b| b.is_ascii()).count();
|
||||
out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default());
|
||||
rest = &piece[ascii..];
|
||||
}
|
||||
self.held.extend_from_slice(rest);
|
||||
let decided = match std::str::from_utf8(&self.held) {
|
||||
Err(e) if e.error_len().is_some() => Some(Line::Native),
|
||||
Ok(_) if ends => Some(Line::Utf8),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(line) = decided {
|
||||
self.line = line;
|
||||
self.flush_held(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ends {
|
||||
self.line = Line::Open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_held(&mut self, out: &mut String) {
|
||||
let held = std::mem::take(&mut self.held);
|
||||
match self.line {
|
||||
Line::Native => self.decode_native(&held, out),
|
||||
Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_native(&mut self, bytes: &[u8], out: &mut String) {
|
||||
match &mut self.native {
|
||||
Some(native) => native.decode_into(bytes, out, false),
|
||||
None => self.utf8.decode_into(bytes, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the rest of the current line as it is.
|
||||
fn end_line(&mut self, out: &mut String) {
|
||||
if self.holds() {
|
||||
self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native };
|
||||
self.flush_held(out);
|
||||
}
|
||||
out.push_str(&self.utf8.finish());
|
||||
self.line = Line::Open;
|
||||
}
|
||||
|
||||
pub fn holds(&self) -> bool {
|
||||
!self.held.is_empty()
|
||||
}
|
||||
|
||||
/// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far.
|
||||
pub fn release(&mut self, out: &mut String) {
|
||||
if self.holds() {
|
||||
self.line = Line::Utf8;
|
||||
self.flush_held(out);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(mut u) = self.utf16.take() {
|
||||
decode_with(&mut u.decoder, &[], &mut out, true);
|
||||
}
|
||||
self.end_line(&mut out);
|
||||
if let Some(native) = &mut self.native {
|
||||
native.decode_into(&[], &mut out, true);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct Utf16 {
|
||||
decoder: encoding_rs::Decoder,
|
||||
/// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds.
|
||||
open: bool,
|
||||
/// An odd number of bytes so far.
|
||||
odd: bool,
|
||||
}
|
||||
|
||||
impl Utf16 {
|
||||
fn new() -> Self {
|
||||
Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false }
|
||||
}
|
||||
|
||||
fn decode(&mut self, bytes: &[u8], out: &mut String) {
|
||||
let start = out.len();
|
||||
decode_with(&mut self.decoder, bytes, out, false);
|
||||
self.odd ^= bytes.len() % 2 == 1;
|
||||
if self.odd {
|
||||
self.open = true;
|
||||
} else if out.len() > start {
|
||||
self.open = !out.ends_with('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of
|
||||
/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a
|
||||
/// character whose low byte came before (and was shown as ASCII).
|
||||
fn utf16_aligned(bytes: &[u8]) -> &[u8] {
|
||||
let (mut even, mut odd) = (0usize, 0usize);
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if *b == 0 {
|
||||
if i % 2 == 0 {
|
||||
even += 1;
|
||||
} else {
|
||||
odd += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if even > odd { &bytes[1..] } else { bytes }
|
||||
}
|
||||
|
||||
fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) {
|
||||
let mut src = bytes;
|
||||
loop {
|
||||
@@ -415,8 +180,7 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
|
||||
|
||||
#[cfg(test)]
|
||||
mod encoding_tests {
|
||||
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
|
||||
use ide_core::output_encoding::encoding_for_code_page;
|
||||
use super::OutputDecoder;
|
||||
|
||||
#[test]
|
||||
fn gbk_split_across_chunks() {
|
||||
@@ -438,157 +202,4 @@ mod encoding_tests {
|
||||
assert_eq!(out, "héllo");
|
||||
assert!(matches!(d, OutputDecoder::Utf8(_)));
|
||||
}
|
||||
|
||||
fn mixed(cp: u32) -> MixedDecoder {
|
||||
MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page")))
|
||||
}
|
||||
|
||||
fn encode(cp: u32, text: &str) -> Vec<u8> {
|
||||
let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text);
|
||||
assert!(!unmappable, "{text} in {cp}");
|
||||
bytes.into_owned()
|
||||
}
|
||||
|
||||
/// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western.
|
||||
const SAMPLES: &[(u32, &str)] = &[
|
||||
(936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"),
|
||||
(950, "系統找不到指定的路徑。"),
|
||||
(949, "지정된 경로를 찾을 수 없습니다."),
|
||||
(932, "指定されたパスが見つかりません。"),
|
||||
(866, "Системе не удается найти указанный путь."),
|
||||
(1251, "Системе не удается найти указанный путь."),
|
||||
(1252, "Le chemin d'accès spécifié est introuvable."),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn utf8_and_code_page_lines_mix_whatever_the_chunks() {
|
||||
for &(cp, native) in SAMPLES {
|
||||
let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec();
|
||||
bytes.extend(encode(cp, native));
|
||||
bytes.extend_from_slice(b"\r\n");
|
||||
bytes.extend_from_slice("ok ✓\n".as_bytes());
|
||||
bytes.extend(encode(cp, native));
|
||||
let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}");
|
||||
for split in 0..=bytes.len() {
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
d.decode_into(&bytes[..split], &mut out);
|
||||
d.decode_into(&bytes[split..], &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, expected, "code page {cp}, split at {split}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_by_byte_code_page_output() {
|
||||
for &(cp, native) in SAMPLES {
|
||||
let bytes = [encode(cp, native), b"\n".to_vec()].concat();
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
for b in &bytes {
|
||||
d.decode_into(std::slice::from_ref(b), &mut out);
|
||||
}
|
||||
assert!(!d.holds());
|
||||
assert_eq!(out, format!("{native}\n"), "code page {cp}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() {
|
||||
let mut d = mixed(936);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"Password: ", &mut out);
|
||||
assert_eq!(out, "Password: ");
|
||||
assert!(!d.holds());
|
||||
|
||||
out.clear();
|
||||
d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out);
|
||||
assert_eq!(out, "请按任意键继续. . .");
|
||||
assert!(!d.holds());
|
||||
|
||||
let mut d = mixed(936);
|
||||
out.clear();
|
||||
d.decode_into("Continue? 继续 ".as_bytes(), &mut out);
|
||||
assert_eq!(out, "Continue? ");
|
||||
assert!(d.holds());
|
||||
d.release(&mut out);
|
||||
assert_eq!(out, "Continue? 继续 ");
|
||||
d.decode_into("[y/N]\n".as_bytes(), &mut out);
|
||||
assert_eq!(out, "Continue? 继续 [y/N]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_line_split_inside_a_character_completes() {
|
||||
let mut d = mixed(949);
|
||||
let mut out = String::new();
|
||||
let bytes = "é中".as_bytes();
|
||||
d.decode_into(&bytes[..3], &mut out);
|
||||
d.release(&mut out);
|
||||
d.decode_into(&bytes[3..], &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "é中");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_code_page_has_no_mixed_decoder() {
|
||||
assert!(NativeDecoder::for_code_page(65001).is_none());
|
||||
}
|
||||
|
||||
fn utf16(text: &str) -> Vec<u8> {
|
||||
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// What `wsl.exe -l -v` writes, on a Chinese system.
|
||||
const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\
|
||||
适用于 Linux 的 Windows 子系统: 默认\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() {
|
||||
let before = " Compiling 编译\n".as_bytes();
|
||||
let wsl = utf16(WSL);
|
||||
let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat();
|
||||
let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n");
|
||||
for split in 0..=wsl.len() {
|
||||
let mut d = mixed(936);
|
||||
let mut out = String::new();
|
||||
for chunk in [before, &wsl[..split], &wsl[split..], &after] {
|
||||
d.decode_into(chunk, &mut out);
|
||||
}
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, expected, "split at {split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_with_a_byte_order_mark_or_starting_with_cjk() {
|
||||
let mut d = mixed(949);
|
||||
let mut out = String::new();
|
||||
d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out);
|
||||
d.decode_into(&utf16("中文 OK\r\n"), &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "한국어 OK\r\n中文 OK\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() {
|
||||
let mut d = MixedDecoder::new(None);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"caf\xe9\n", &mut out);
|
||||
d.decode_into(&utf16("wsl 子系统\r\n"), &mut out);
|
||||
d.decode_into("ok ✓\n".as_bytes(), &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() {
|
||||
for cp in [437, 850, 852] {
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"caf\x82\r\n", &mut out);
|
||||
assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console.
|
||||
fn run(command: &str) -> String {
|
||||
let (tx, events) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
|
||||
let _ = tx.lock().send(e);
|
||||
});
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = RunConfig {
|
||||
id: "t:out".into(),
|
||||
name: "t:out".into(),
|
||||
kind: "shell".into(),
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let chunks: Arc<Mutex<Vec<OutputChunk>>> = Arc::default();
|
||||
let out = chunks.clone();
|
||||
let pm = ProcessManager::new();
|
||||
let info = pm
|
||||
.start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c)))
|
||||
.expect("start");
|
||||
loop {
|
||||
match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") {
|
||||
IdeEvent::RunExited { run, .. } if run == info.id => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
chunks
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR,
|
||||
/// 437…, by the system's language), and still read right in a UTF-8 console.
|
||||
#[test]
|
||||
fn native_messages_in_the_console_code_page_are_decoded() {
|
||||
let text = run(r"dir C:\openidea-no-such-dir");
|
||||
assert!(text.trim().lines().count() >= 2, "{text:?}");
|
||||
assert!(!text.contains('\u{FFFD}'), "{text:?}");
|
||||
}
|
||||
|
||||
/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language.
|
||||
#[test]
|
||||
fn utf16_output_is_decoded() {
|
||||
let text = run("cmd /u /c echo 中文 한국어 OK");
|
||||
assert_eq!(text, "中文 한국어 OK\r\n");
|
||||
}
|
||||
@@ -19,6 +19,8 @@ tar = "0.4"
|
||||
zip.workspace = true
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
|
||||
@@ -20,8 +20,6 @@ mod bundled;
|
||||
pub mod formatter;
|
||||
pub mod jdk;
|
||||
pub mod platform;
|
||||
#[cfg(any(windows, test))]
|
||||
pub mod portable_update;
|
||||
mod process;
|
||||
pub mod registry;
|
||||
pub mod server;
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
//! In-place updates of the Windows unpack-and-run distribution.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::fs;
|
||||
use std::io::{self, Cursor};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `bytes` must already have passed the updater's signature verification.
|
||||
pub fn install(exe: &Path, bytes: &[u8]) -> IdeResult<()> {
|
||||
let directory = exe.parent().ok_or_else(|| IdeError::invalid("executable has no parent directory"))?;
|
||||
let staging = tempfile::Builder::new().prefix(".openidea-update-").tempdir_in(directory)?;
|
||||
let files = unpack(bytes, staging.path(), exe)?;
|
||||
let mut replaced = Vec::new();
|
||||
for (source, target) in files {
|
||||
match replace_file(&source, &target) {
|
||||
Ok(backup) => replaced.push((target, backup)),
|
||||
Err(error) => {
|
||||
for (target, backup) in replaced.into_iter().rev() {
|
||||
fs::remove_file(&target)?;
|
||||
if let Some(backup) = backup {
|
||||
fs::rename(&backup, &target).map_err(|restore| {
|
||||
IdeError::internal(format!(
|
||||
"{error}; restore {} from {}: {restore}",
|
||||
target.display(),
|
||||
backup.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Old executables stay locked until their process exits; retry cleanup on the next start.
|
||||
pub fn cleanup(exe: &Path) {
|
||||
for target in [exe.to_path_buf(), exe.with_file_name("WebView2Loader.dll")] {
|
||||
let _ = fs::remove_file(backup_path(&target));
|
||||
}
|
||||
}
|
||||
|
||||
fn unpack(bytes: &[u8], staging: &Path, exe: &Path) -> IdeResult<Vec<(PathBuf, PathBuf)>> {
|
||||
let invalid = |error| IdeError::invalid(format!("portable update archive: {error}"));
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
|
||||
let mut files = Vec::new();
|
||||
let mut has_exe = false;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index).map_err(invalid)?;
|
||||
let name = entry.name().map_err(invalid)?;
|
||||
let target = match name.as_ref() {
|
||||
"openidea.exe" => {
|
||||
has_exe = true;
|
||||
exe.to_path_buf()
|
||||
}
|
||||
"WebView2Loader.dll" => exe.with_file_name("WebView2Loader.dll"),
|
||||
name => return Err(IdeError::invalid(format!("unexpected portable update file: {name}"))),
|
||||
};
|
||||
if !entry.is_file() || entry.is_symlink() || files.iter().any(|(_, path)| path == &target) {
|
||||
return Err(IdeError::invalid("portable update contains a duplicate or non-regular file"));
|
||||
}
|
||||
let source = staging.join(name.as_ref());
|
||||
let mut file = fs::File::create(&source)?;
|
||||
io::copy(&mut entry, &mut file)?;
|
||||
file.sync_all()?;
|
||||
files.push((source, target));
|
||||
}
|
||||
if !has_exe {
|
||||
return Err(IdeError::invalid("portable update is missing openidea.exe"));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn replace_file(source: &Path, target: &Path) -> IdeResult<Option<PathBuf>> {
|
||||
let backup = if target.try_exists()? {
|
||||
if !target.is_file() {
|
||||
return Err(IdeError::invalid(format!("not an application file: {}", target.display())));
|
||||
}
|
||||
let backup = backup_path(target);
|
||||
if backup.try_exists()? {
|
||||
fs::remove_file(&backup)?;
|
||||
}
|
||||
// Windows forbids overwriting a running exe, but allows renaming it.
|
||||
fs::rename(target, &backup)?;
|
||||
Some(backup)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(error) = fs::rename(source, target) {
|
||||
if let Some(backup) = &backup {
|
||||
fs::rename(backup, target).map_err(|restore| {
|
||||
IdeError::internal(format!(
|
||||
"{error}; restore {} from {}: {restore}",
|
||||
target.display(),
|
||||
backup.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
fn backup_path(target: &Path) -> PathBuf {
|
||||
let mut path = target.as_os_str().to_os_string();
|
||||
path.push(".openidea-old");
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn package(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_application_files_and_preserves_configuration() {
|
||||
for name in ["openidea.exe", "我的 IDE.exe"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe = tmp.path().join(name);
|
||||
fs::write(&exe, b"old exe").unwrap();
|
||||
fs::write(tmp.path().join("WebView2Loader.dll"), b"old dll").unwrap();
|
||||
fs::write(tmp.path().join("settings.toml"), b"my settings").unwrap();
|
||||
fs::create_dir(tmp.path().join("data")).unwrap();
|
||||
fs::write(tmp.path().join("data/plugins.json"), b"my plugins").unwrap();
|
||||
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
|
||||
install(&exe, &bytes).unwrap();
|
||||
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
|
||||
assert_eq!(fs::read(tmp.path().join("WebView2Loader.dll")).unwrap(), b"new dll");
|
||||
assert_eq!(fs::read(tmp.path().join("settings.toml")).unwrap(), b"my settings");
|
||||
assert_eq!(fs::read(tmp.path().join("data/plugins.json")).unwrap(), b"my plugins");
|
||||
cleanup(&exe);
|
||||
assert!(!backup_path(&exe).exists());
|
||||
assert!(!backup_path(&tmp.path().join("WebView2Loader.dll")).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_an_exe_only_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe = tmp.path().join("openidea.exe");
|
||||
fs::write(&exe, b"old").unwrap();
|
||||
install(&exe, &package(&[("openidea.exe", b"new")])).unwrap();
|
||||
assert_eq!(fs::read(&exe).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_unexpected_payloads_before_changing_files() {
|
||||
for files in [
|
||||
vec![("WebView2Loader.dll", b"new".as_slice())],
|
||||
vec![("openidea.exe", b"new"), ("settings.toml", b"overwrite")],
|
||||
vec![("openidea.exe", b"new"), ("../outside.txt", b"escape")],
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe = tmp.path().join("openidea.exe");
|
||||
fs::write(&exe, b"old").unwrap();
|
||||
assert!(install(&exe, &package(&files)).is_err());
|
||||
assert_eq!(fs::read(&exe).unwrap(), b"old");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn updates_a_running_executable_and_restores_on_a_locked_companion() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe = tmp.path().join("openidea.exe");
|
||||
let ping = Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/PING.EXE");
|
||||
fs::copy(ping, &exe).unwrap();
|
||||
let original = fs::read(&exe).unwrap();
|
||||
let dll = tmp.path().join("WebView2Loader.dll");
|
||||
fs::write(&dll, b"old dll").unwrap();
|
||||
let mut command = Command::new(&exe);
|
||||
command.args(["-n", "30", "127.0.0.1"]).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
ide_core::process::prepare(&mut command);
|
||||
let mut child = command.spawn().unwrap();
|
||||
let result = {
|
||||
assert!(fs::write(&exe, b"overwrite").is_err(), "fixture must hold the running exe");
|
||||
let held = fs::OpenOptions::new().read(true).share_mode(1).open(&dll).unwrap();
|
||||
let bytes = package(&[("openidea.exe", b"new exe"), ("WebView2Loader.dll", b"new dll")]);
|
||||
assert!(install(&exe, &bytes).is_err());
|
||||
assert_eq!(fs::read(&exe).unwrap(), original);
|
||||
assert_eq!(fs::read(&dll).unwrap(), b"old dll");
|
||||
drop(held);
|
||||
install(&exe, &bytes)
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
result.unwrap();
|
||||
assert_eq!(fs::read(&exe).unwrap(), b"new exe");
|
||||
assert_eq!(fs::read(&dll).unwrap(), b"new dll");
|
||||
cleanup(&exe);
|
||||
assert!(!backup_path(&exe).exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn restores_the_old_file_when_the_new_file_cannot_be_moved() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe = tmp.path().join("openidea.exe");
|
||||
let source = tmp.path().join("new.exe");
|
||||
fs::write(&exe, b"old").unwrap();
|
||||
fs::write(&source, b"new").unwrap();
|
||||
let _held = fs::OpenOptions::new().read(true).share_mode(1).open(&source).unwrap();
|
||||
assert!(replace_file(&source, &exe).is_err());
|
||||
assert_eq!(fs::read(&exe).unwrap(), b"old");
|
||||
assert_eq!(fs::read(&source).unwrap(), b"new");
|
||||
assert!(!backup_path(&exe).exists());
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ On your own messages you can **Edit** (the messages after it are replaced; files
|
||||
|
||||
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
|
||||
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and command permissions don't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and the Command Execution Policy doesn't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
|
||||
### Review the changes
|
||||
|
||||
|
||||
+2
-16
@@ -85,10 +85,8 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
|
||||
| Search | `search_text`, `search_regex`, `search_symbol`, `search_file` |
|
||||
| Editors | `get_all_open_file_paths`, `get_current_file`, `open_file_in_editor` |
|
||||
| Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` |
|
||||
| Problems | `get_file_problems`, `get_project_problems`, `check_files` |
|
||||
| Problems | `get_file_problems`, `get_project_problems` |
|
||||
| Run and build | `get_run_configurations`, `execute_run_configuration`, `build_project` |
|
||||
| Commands | `run_command`, `wait_for_run`, `send_run_input`, `stop_run` |
|
||||
| Command state | `get_run_status`, `read_run_output`, `list_runs` |
|
||||
| Git | `get_repositories`, `git_status`, `git_diff` |
|
||||
|
||||
Notes on what they do:
|
||||
@@ -96,28 +94,16 @@ Notes on what they do:
|
||||
- `read_file` also reads sources of dependencies and files inside jar and zip archives.
|
||||
- `search_symbol` and `find_references` use the IDE's index and the language servers; `get_symbol_info` returns what Quick Documentation shows.
|
||||
- `get_file_problems` and `get_project_problems` return what the Problems tool window shows, after your inspection profile is applied, rather than a fresh analysis.
|
||||
- `check_files` is the fresh analysis, for after an edit made by any tool: it syncs each file from disk, tells its language servers it was saved (so on-save checks such as `cargo check` run) and waits until they answered and finished. Each file gets a `status` that says whether the problems are complete, or why it could not be checked: no language server for it, or one that is not installed, misconfigured or crashed.
|
||||
- `rename_refactoring` renames through the language server, so references that a text search would miss are updated too. It can preview without writing.
|
||||
- `apply_patch` accepts the Codex `apply_patch` format and unified diffs. Paths must stay inside the project.
|
||||
- `git_diff` compares files on disk, so unsaved editor text is not included.
|
||||
|
||||
## Commands in the Run tool window
|
||||
|
||||
`run_command` runs a shell command line in a tab of the Run tool window, with the shell of **Settings › Tools › Terminal**. The agent names the tab and OpenIDEA shows it as `_agent_<name>`, so you see the output as it comes and can type into the command, rerun it or stop it like any run.
|
||||
|
||||
As JetBrains IDEs do, the command runs on pipes, not a terminal: programs print plain lines instead of progress bars and redraws. The agent gets that output cleaned of escape sequences and cut to 2000 lines (`maxLinesCount`); beyond that the end stays, or the start or both ends with `truncateMode`. The tab's raw output log stays complete.
|
||||
|
||||
- **One tab per name.** A name whose command finished runs the next command in the same tab. A name whose command is still running is refused, with that command and its last output: the agent has to stop it with `stop_run` first or pick another name. Nothing is stopped behind your back.
|
||||
- **Following a command up.** `wait_for_run` waits for the command to end or for a pattern in its output (a server's "listening" line), `read_run_output` reads the output from any point, `get_run_status` tells whether it is running, succeeded, failed, crashed or was stopped by the agent or by you, and `send_run_input` writes text and Enter to its input; Ctrl+C stops it and Ctrl+D ends its input.
|
||||
- **You can step in.** What you type into the tab's input line, a rerun, a Stop and closing the tab are reported to the agent with their time. A wait ends at once, and the agent is advised to stop what it is doing, tell you it received the intervention and wait for your confirmation.
|
||||
- **Finished tabs are greyed out.** Beyond **Finished agent tabs to show** (1 by default) the oldest finished ones move to an archive at the end of the tab strip, shown as **Archived (n)**. Beyond **Archived agent tabs to keep** (999) the oldest archived ones are closed. Running commands are never archived or closed. Both settings are on **Settings › Agents › IDE MCP Server**.
|
||||
|
||||
Plugins can add more tools; they appear in the same list. A plugin tool marked as destructive asks for confirmation the same way.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Untrusted projects** expose only the read-only tools. Tools that write files, rename, reformat, build or run need a project you have trusted.
|
||||
- **Running and building ask first.** `execute_run_configuration`, `build_project` and `run_command` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- **Running and building ask first.** `execute_run_configuration` and `build_project` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- Tools that write refuse paths outside the project.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
|
||||
### 查看改动
|
||||
|
||||
|
||||
+2
-16
@@ -84,10 +84,8 @@ stdio 配置大致如下(路径以你机器上的为准):
|
||||
| 搜索 | `search_text`、`search_regex`、`search_symbol`、`search_file` |
|
||||
| 编辑器 | `get_all_open_file_paths`、`get_current_file`、`open_file_in_editor` |
|
||||
| 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` |
|
||||
| 问题 | `get_file_problems`、`get_project_problems`、`check_files` |
|
||||
| 问题 | `get_file_problems`、`get_project_problems` |
|
||||
| 运行与构建 | `get_run_configurations`、`execute_run_configuration`、`build_project` |
|
||||
| 命令 | `run_command`、`wait_for_run`、`send_run_input`、`stop_run` |
|
||||
| 命令状态 | `get_run_status`、`read_run_output`、`list_runs` |
|
||||
| Git | `get_repositories`、`git_status`、`git_diff` |
|
||||
|
||||
几点说明:
|
||||
@@ -95,28 +93,16 @@ stdio 配置大致如下(路径以你机器上的为准):
|
||||
- `read_file` 还能读取依赖的源代码,以及 jar 和 zip 压缩包中的文件。
|
||||
- `search_symbol` 和 `find_references` 使用 IDE 的索引和语言服务器;`get_symbol_info` 返回的内容与快速文档相同。
|
||||
- `get_file_problems` 和 `get_project_problems` 返回的是问题工具窗口当前显示的内容(已应用你的检查配置文件),而不是重新分析的结果。
|
||||
- `check_files` 才是重新分析,适合在任何工具修改文件之后调用:它从磁盘同步每个文件,通知语言服务器文件已保存(这样 `cargo check` 等保存时检查也会运行),并等到服务器答复且分析完成。每个文件都有一个 `status`,说明问题列表是否完整,或者为什么无法检查:没有对应的语言服务器,或者服务器未安装、配置错误、已崩溃。
|
||||
- `rename_refactoring` 通过语言服务器重命名,文本搜索找不到的引用也会一并更新;也可以只预览不写入。
|
||||
- `apply_patch` 接受 Codex 的 `apply_patch` 格式和 unified diff,路径必须在项目内。
|
||||
- `git_diff` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
|
||||
|
||||
## 在运行工具窗口中执行命令
|
||||
|
||||
`run_command` 在运行工具窗口的一个标签页中,用 **设置 › 工具 › 终端** 中配置的 shell 执行一条命令行。标签名由智能体指定,OpenIDEA 显示为 `_agent_<名称>`。输出实时可见,你也可以像对待其他运行一样向命令输入、重新运行或停止它。
|
||||
|
||||
和 JetBrains 的 IDE 一样,命令通过管道运行,而不是在终端里运行:程序输出的是普通文本行,不会有进度条和屏幕重绘。智能体拿到的输出会去掉控制字符,并截断到 2000 行(`maxLinesCount`);超出部分默认保留结尾,也可以用 `truncateMode` 改为保留开头或两头。标签页的原始输出日志保持完整。
|
||||
|
||||
- **一个名称对应一个标签。** 名称对应的命令已结束时,下一条命令在同一标签中运行。命令仍在运行时会被拒绝,并返回该命令和它最后的输出:智能体必须先用 `stop_run` 停止它,或者换一个名称。任何进程都不会被悄悄停掉。
|
||||
- **跟进命令。** `wait_for_run` 等待命令结束,或等待输出中出现指定文本(例如服务器的“正在监听”一行);`read_run_output` 从任意位置读取输出;`get_run_status` 报告命令是在运行、成功、失败、崩溃,还是被智能体或你停止;`send_run_input` 向命令的输入写入文本和 Enter,Ctrl+C 会停止命令,Ctrl+D 结束它的输入。
|
||||
- **你可以随时干预。** 你在标签输入行中输入的内容、重新运行、停止和关闭标签,都会连同时间一起告诉智能体。正在进行的等待会立即返回,并建议智能体停下手头的工作,回复你已收到干预,等你确认后再继续。
|
||||
- **已结束的标签会置灰。** 已结束的标签超过 **显示的已结束智能体标签数**(默认 1)时,最旧的会移入标签栏末尾的归档,显示为 **归档(n)**。归档超过 **保留的已归档智能体标签数**(默认 999)时,最旧的会被关闭。正在运行的命令永远不会被归档或关闭。这两项设置位于 **设置 › 智能体 › IDE MCP 服务器**。
|
||||
|
||||
插件可以添加更多工具,它们会出现在同一个列表中。标记为破坏性操作的插件工具同样会先请求确认。
|
||||
|
||||
## 安全
|
||||
|
||||
- **未受信任的项目** 只开放只读工具。写文件、重命名、重新格式化、构建或运行的工具都要求项目已被信任。
|
||||
- **运行和构建会先询问。** `execute_run_configuration`、`build_project` 和 `run_command` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- **运行和构建会先询问。** `execute_run_configuration` 和 `build_project` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- 会写入的工具拒绝项目之外的路径。
|
||||
|
||||
## 限制
|
||||
|
||||
@@ -11,14 +11,6 @@ describe("mirrorManifest", () => {
|
||||
platforms: {
|
||||
"darwin-aarch64": { signature: "sig-mac", url: `${gh}/OpenIDEA_aarch64.app.tar.gz` },
|
||||
"windows-x86_64-nsis": { signature: "sig-win", url: `${gh}/OpenIDEA_0.1.38_x64-setup.exe` },
|
||||
"windows-x86_64-portable": {
|
||||
signature: "sig-portable",
|
||||
url: `${gh}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
|
||||
},
|
||||
"windows-aarch64-portable": {
|
||||
signature: "sig-portable-arm",
|
||||
url: `${gh}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,14 +21,6 @@ describe("mirrorManifest", () => {
|
||||
platforms: {
|
||||
"darwin-aarch64": { signature: "sig-mac", url: `${cnb}/OpenIDEA_aarch64.app.tar.gz` },
|
||||
"windows-x86_64-nsis": { signature: "sig-win", url: `${cnb}/OpenIDEA_0.1.38_x64-setup.exe` },
|
||||
"windows-x86_64-portable": {
|
||||
signature: "sig-portable",
|
||||
url: `${cnb}/OpenIDEA_0.1.38_windows_x64_portable.zip`,
|
||||
},
|
||||
"windows-aarch64-portable": {
|
||||
signature: "sig-portable-arm",
|
||||
url: `${cnb}/OpenIDEA_0.1.38_windows_arm64_portable.zip`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const workflow = readFileSync(new URL("../.github/workflows/release-build.yml", import.meta.url), "utf8").replaceAll(
|
||||
"\r\n",
|
||||
"\n",
|
||||
);
|
||||
const job = workflow.split("\n updater-manifest:")[1]!;
|
||||
const script = job.split(" script: |\n")[1]!.replace(/^ {12}/gm, "");
|
||||
const run = new Function("github", "context", "core", `return (async () => {${script}\n})();`);
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it.each(["v0.1.42", "v0.1.43-nightly.20261112"])(
|
||||
"publishes signed portable updates for both Windows architectures alongside the installers (%s)",
|
||||
async (tag) => {
|
||||
vi.stubEnv("TAG", tag);
|
||||
vi.stubEnv("RELEASE_ID", "42");
|
||||
const version = tag.slice(1);
|
||||
const nightly = tag.includes("-nightly.");
|
||||
const bundles = [
|
||||
"OpenIDEA_aarch64.AppImage",
|
||||
"OpenIDEA_arm64.deb",
|
||||
"OpenIDEA.aarch64.rpm",
|
||||
"OpenIDEA_amd64.AppImage",
|
||||
"OpenIDEA_amd64.deb",
|
||||
"OpenIDEA.x86_64.rpm",
|
||||
"OpenIDEA_aarch64.app.tar.gz",
|
||||
"OpenIDEA_x64.app.tar.gz",
|
||||
"OpenIDEA_arm64_en-US.msi",
|
||||
"OpenIDEA_arm64-setup.exe",
|
||||
"OpenIDEA_x64_en-US.msi",
|
||||
"OpenIDEA_x64-setup.exe",
|
||||
`OpenIDEA_${version}_windows_x64_portable.zip`,
|
||||
`OpenIDEA_${version}_windows_arm64_portable.zip`,
|
||||
].filter((name) => !nightly || (!name.endsWith(".msi") && !name.endsWith(".rpm")));
|
||||
const assets = bundles.flatMap((name, id) => [
|
||||
{ name, id: id * 2 },
|
||||
{ name: `${name}.sig`, id: id * 2 + 1 },
|
||||
]);
|
||||
const upload = vi.fn();
|
||||
const failed = vi.fn();
|
||||
await run(
|
||||
{
|
||||
paginate: async () => assets,
|
||||
rest: {
|
||||
repos: {
|
||||
listReleaseAssets: vi.fn(),
|
||||
getRelease: async () => ({ data: { body: "Release notes" } }),
|
||||
getReleaseAsset: async ({ asset_id }: { asset_id: number }) => ({
|
||||
data: Buffer.from(`signature-${asset_id}\n`),
|
||||
}),
|
||||
uploadReleaseAsset: upload,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ repo: { owner: "l0ng-ai", repo: "openidea" } },
|
||||
{ notice: vi.fn(), info: vi.fn(), setFailed: failed },
|
||||
);
|
||||
expect(failed).not.toHaveBeenCalled();
|
||||
const manifest = JSON.parse(upload.mock.calls[0]![0].data);
|
||||
for (const [arch, filename] of [
|
||||
["x86_64", "x64"],
|
||||
["aarch64", "arm64"],
|
||||
]) {
|
||||
const name = `OpenIDEA_${version}_windows_${filename}_portable.zip`;
|
||||
const sig = assets.find((a) => a.name === `${name}.sig`)!;
|
||||
expect(manifest.platforms[`windows-${arch}-portable`]).toEqual({
|
||||
signature: `signature-${sig.id}`,
|
||||
url: `https://github.com/l0ng-ai/openidea/releases/download/${tag}/${name}`,
|
||||
});
|
||||
expect(manifest.platforms[`windows-${arch}`].url).toContain(nightly ? "-setup.exe" : "_en-US.msi");
|
||||
expect(manifest.platforms[`windows-${arch}-nsis`].url).toContain("-setup.exe");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -22,37 +22,6 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
|
||||
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).request_text(move |_, text| {
|
||||
if let Some(sender) = sender.take() {
|
||||
let _ = sender.send(text.map(|t| t.to_string()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.map_err(|error| IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")))?;
|
||||
Ok(rx.await.ok().flatten())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
with_clipboard(app, || Ok(ide_clipboard::text())).await
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute paths on the clipboard, empty when it holds no file list.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -140,15 +140,6 @@ pub fn mcp_ui_ready(window: Window, state: State<'_, AppState>, ready: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user closed the Run tool window tab of an agent command (`run_command`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_agent_run_closed(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
state.host.mcp_agent_runs.closed(&project.root, &name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_respond(
|
||||
|
||||
@@ -74,7 +74,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
file_dialog::gtk_pick_paths,
|
||||
mcp::mcp_ui_ready,
|
||||
mcp::mcp_respond,
|
||||
mcp::mcp_agent_run_closed,
|
||||
mcp::mcp_server_status,
|
||||
mcp::mcp_set_open_editors,
|
||||
mcp::mcp_set_focused_editor,
|
||||
@@ -188,7 +187,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_read_text,
|
||||
fs::fs_open_with_system,
|
||||
clipboard::clipboard_write_text,
|
||||
clipboard::clipboard_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_image,
|
||||
|
||||
@@ -464,15 +464,7 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
|
||||
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut builder = app.updater_builder();
|
||||
if let Some(target) = crate::updates::portable_target(
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
tauri::utils::platform::bundle_type(),
|
||||
) {
|
||||
builder = builder.target(target);
|
||||
}
|
||||
builder
|
||||
app.updater_builder()
|
||||
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
|
||||
.endpoints(endpoints)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
@@ -619,16 +611,10 @@ pub async fn app_install_update(
|
||||
_ = token.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let _ = on_progress.send(UpdateProgress::Installing);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(windows)]
|
||||
if update.target.ends_with("-portable") {
|
||||
let exe = tauri::utils::platform::current_exe()?;
|
||||
return ide_tools::portable_update::install(&exe, &bytes);
|
||||
}
|
||||
update.install(bytes).map_err(|e| IdeError::internal(e.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
tauri::async_runtime::spawn_blocking(move || update.install(bytes))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
.await;
|
||||
state.host.end_request(request);
|
||||
|
||||
@@ -176,12 +176,6 @@ pub fn run() {
|
||||
})
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
#[cfg(windows)]
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
if let Ok(exe) = tauri::utils::platform::current_exe() {
|
||||
ide_tools::portable_update::cleanup(&exe);
|
||||
}
|
||||
});
|
||||
// An unusable directory variable stops the start with a message, never a silent fallback.
|
||||
let identifier = app.config().identifier.clone();
|
||||
let local_data_dir = local_data_dir.and_then(|dir| {
|
||||
|
||||
@@ -11,11 +11,6 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Tauri patches the installer copies; the raw exe shipped in the portable zip keeps no bundle type.
|
||||
pub fn portable_target(os: &str, arch: &str, bundle: Option<tauri::utils::config::BundleType>) -> Option<String> {
|
||||
(os == "windows" && bundle.is_none()).then(|| format!("windows-{arch}-portable"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Updates {
|
||||
started: AtomicBool,
|
||||
@@ -168,20 +163,6 @@ fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet<String>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_unbundled_windows_builds_select_portable_updates() {
|
||||
use tauri::utils::config::BundleType;
|
||||
for arch in ["x86_64", "aarch64"] {
|
||||
assert_eq!(portable_target("windows", arch, None), Some(format!("windows-{arch}-portable")));
|
||||
for bundle in [BundleType::Msi, BundleType::Nsis] {
|
||||
assert_eq!(portable_target("windows", arch, Some(bundle)), None);
|
||||
}
|
||||
for os in ["linux", "macos"] {
|
||||
assert_eq!(portable_target(os, arch, None), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_only_target_live_owners_that_have_subscribed() {
|
||||
let live = vec!["main".into(), "project-2".into()];
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost; frame-ancestors 'none'"
|
||||
"csp": "default-src 'self'; img-src 'self' asset: data: blob: http: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+1
-18
@@ -17,8 +17,6 @@ export const commands = {
|
||||
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
|
||||
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
|
||||
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
|
||||
/** The user closed the Run tool window tab of an agent command (`run_command`). */
|
||||
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
|
||||
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
|
||||
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
|
||||
mcpSetFocusedEditor: (editor: {
|
||||
@@ -282,12 +280,6 @@ export const commands = {
|
||||
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
|
||||
/** Writes text locally, without WebView user-activation requirements. */
|
||||
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
|
||||
/**
|
||||
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
* text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
*/
|
||||
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
|
||||
/** Absolute paths on the clipboard, empty when it holds no file list. */
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
@@ -7191,10 +7183,6 @@ export type McpServerSettings = {
|
||||
* (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
*/
|
||||
runWithoutConfirmation?: boolean,
|
||||
/** Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived. */
|
||||
agentTabLimit?: number,
|
||||
/** Archived agent tabs kept; older ones are closed. */
|
||||
agentArchiveLimit?: number,
|
||||
};
|
||||
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
@@ -7216,12 +7204,7 @@ export type McpServerStatus = {
|
||||
/** Transport of an MCP server configured in Settings › Agents › MCP Servers. */
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
|
||||
/**
|
||||
* What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
* line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
* started or failed to.
|
||||
*/
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null } | { type: "agentRun"; name: string; command: string; cwd: IpcPath; env: { [key in string]: string } };
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null };
|
||||
|
||||
export type MemoryCategory =
|
||||
/** The backend and its WebView processes. */
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { history, undo } from "@codemirror/commands";
|
||||
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
beforeEach(() => void vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
|
||||
|
||||
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Types `text` at `at` the way a keystroke does. */
|
||||
const type = (v: EditorView, at: number, text: string) =>
|
||||
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
|
||||
|
||||
/** What a full rebuild of the same text renders, in a fresh editor state. */
|
||||
function fullRebuild(doc: string): Array<[number, number]> {
|
||||
return renderedRanges(
|
||||
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
|
||||
);
|
||||
}
|
||||
|
||||
const pause = () => vi.advanceTimersByTime(300);
|
||||
|
||||
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
|
||||
const v = open();
|
||||
const [before] = renderedRanges(v.state);
|
||||
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
|
||||
// comment at once. Typing only moves it along until the pause.
|
||||
type(v, v.state.doc.line(2).from + 2, "/*");
|
||||
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders a pasted comment at once", () => {
|
||||
const v = open();
|
||||
v.dispatch({
|
||||
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
|
||||
userEvent: "input.paste",
|
||||
});
|
||||
expect(renderedRanges(v.state)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("brings a deleted comment back rendered at once on undo", () => {
|
||||
const v = open(DOC, [history()]);
|
||||
const line = v.state.doc.line(4);
|
||||
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
undo(v);
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rebuilds at once for a keystroke on a comment's line", () => {
|
||||
const v = open();
|
||||
// Code after the closing marker: the comment no longer has its line to itself.
|
||||
type(v, v.state.doc.line(4).to, " int x;");
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still shows the source of a comment the caret lands in after fast typing", () => {
|
||||
const v = open();
|
||||
type(v, v.state.doc.line(2).from + 2, "xx");
|
||||
const at = v.state.doc.toString().indexOf("Books");
|
||||
v.dispatch({ selection: EditorSelection.cursor(at) });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
|
||||
// A seeded generator, so a failure replays.
|
||||
let seed = 7;
|
||||
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const lines = ["class A {"];
|
||||
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
|
||||
lines.push("}");
|
||||
const v = open(lines.join("\n"));
|
||||
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const at = Math.floor(next() * (v.state.doc.length + 1));
|
||||
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
|
||||
else type(v, at, keys[Math.floor(next() * keys.length)]!);
|
||||
if (next() < 0.05) pause();
|
||||
}
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
});
|
||||
@@ -25,13 +25,8 @@
|
||||
* Off, which is the default, the icon still shows over a comment, so one comment can be
|
||||
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
|
||||
* comments and none is flipped, its comments are found only once typing pauses
|
||||
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* only if the file has doc comments.
|
||||
*
|
||||
* On, typing costs about as little: a keystroke away from every comment's lines only
|
||||
* moves the rendered comments along with the text, and the file is scanned again once
|
||||
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
|
||||
* once, so the comments it brings in render at once.
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
@@ -41,7 +36,6 @@ import {
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type ChangeSet,
|
||||
type Extension,
|
||||
type Transaction,
|
||||
} from "@codemirror/state";
|
||||
@@ -375,55 +369,11 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
|
||||
|
||||
interface DocsView {
|
||||
decorations: DecorationSet;
|
||||
/** Every doc comment, rendered or not, in document order. */
|
||||
/** Every doc comment, rendered or not. */
|
||||
comments: ReadonlyArray<PlacedComment>;
|
||||
/**
|
||||
* Moved along with keystrokes since the last scan, instead of found again: what the
|
||||
* parser made of them may differ, until `deferredScanner` rebuilds.
|
||||
*/
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
|
||||
|
||||
/** Asks for the scan a stale view is waiting for. */
|
||||
const rescanDocs = StateEffect.define<null>();
|
||||
|
||||
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
|
||||
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
|
||||
return comments.map((c) => ({
|
||||
...c,
|
||||
from: changes.mapPos(c.from, 1),
|
||||
to: changes.mapPos(c.to, -1),
|
||||
line: changes.mapPos(c.line, 1),
|
||||
end: changes.mapPos(c.end, -1),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
|
||||
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
|
||||
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
|
||||
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
|
||||
* the file one), which the scan after the pause takes in.
|
||||
*/
|
||||
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
|
||||
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
|
||||
const { comments } = view;
|
||||
let touches = false;
|
||||
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||
// The first comment that does not end before the change.
|
||||
let lo = 0;
|
||||
let hi = comments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (comments[mid]!.end < fromA) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
|
||||
});
|
||||
return !touches;
|
||||
}
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
|
||||
|
||||
/**
|
||||
* Rendered comments by their source, shared by every editor, least recently used first.
|
||||
@@ -492,7 +442,7 @@ function build(state: EditorState): DocsView {
|
||||
),
|
||||
);
|
||||
}
|
||||
return { decorations: Decoration.set(decorations), comments, stale: false };
|
||||
return { decorations: Decoration.set(decorations), comments };
|
||||
}
|
||||
|
||||
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
|
||||
@@ -526,7 +476,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
|
||||
/**
|
||||
* The doc comments of a file that renders none, for their icons. Rescanning the file on
|
||||
* each keystroke would make typing in a large file several times slower for a feature
|
||||
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
|
||||
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
|
||||
* found are mapped through the edits. Empty while the file renders comments: `docsField`
|
||||
* has them then.
|
||||
*/
|
||||
@@ -537,29 +487,32 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
|
||||
// Switched off: the comments rendered a moment ago are known only to the last scan.
|
||||
if (active(tr.startState)) return placeComments(tr.state);
|
||||
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
|
||||
return tr.docChanged ? mapComments(value, tr.changes) : value;
|
||||
if (!tr.docChanged) return value;
|
||||
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
|
||||
return value.map((c) => ({
|
||||
...c,
|
||||
from: map(c.from, 1),
|
||||
to: map(c.to, -1),
|
||||
line: map(c.line, 1),
|
||||
end: map(c.end, -1),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Scans the file once edits and the parser have paused: a file that renders no comments,
|
||||
* for their icons; one that does, when keystrokes have only moved its comments along.
|
||||
*/
|
||||
const deferredScanner = ViewPlugin.fromClass(
|
||||
/** Scans a file that renders no comments once the edits and the parser have paused. */
|
||||
const passiveScanner = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(readonly view: EditorView) {}
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
|
||||
// Rendering and up to date: the view was just built, nothing is waiting.
|
||||
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const { state } = this.view;
|
||||
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
|
||||
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
|
||||
}, RESCAN_DELAY);
|
||||
if (active(update.state)) return this.cancel();
|
||||
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
|
||||
}, PASSIVE_SCAN_DELAY);
|
||||
}
|
||||
}
|
||||
cancel() {
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
@@ -571,8 +524,8 @@ const deferredScanner = ViewPlugin.fromClass(
|
||||
},
|
||||
);
|
||||
|
||||
/** How long edits and parsing must pause before the file is scanned again. */
|
||||
const RESCAN_DELAY = 300;
|
||||
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
|
||||
const PASSIVE_SCAN_DELAY = 300;
|
||||
|
||||
/** Every doc comment of the file, as last found. */
|
||||
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
|
||||
@@ -634,26 +587,13 @@ const docsField = StateField.define<DocsView>({
|
||||
create: build,
|
||||
update(value, tr) {
|
||||
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
|
||||
if (
|
||||
const rebuild =
|
||||
tr.docChanged ||
|
||||
tr.reconfigured ||
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
|
||||
)
|
||||
return build(tr.state);
|
||||
if (tr.docChanged) {
|
||||
// A keystroke away from every comment moves them along; the scan waits for a pause.
|
||||
if (active(tr.state) && typedOutsideComments(value, tr))
|
||||
return {
|
||||
decorations: value.decorations.map(tr.changes),
|
||||
comments: mapComments(value.comments, tr.changes),
|
||||
stale: true,
|
||||
};
|
||||
return build(tr.state);
|
||||
}
|
||||
// The parser finishing more of the file can bring comments it had not reached: built
|
||||
// at once, so a file opens with the comments on screen rendered, unless keystrokes
|
||||
// already have the scan waiting for a pause.
|
||||
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
|
||||
return value;
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
|
||||
// The parser finishing more of the file can bring comments it had not reached.
|
||||
syntaxTree(tr.startState) !== syntaxTree(tr.state);
|
||||
return rebuild ? build(tr.state) : value;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
|
||||
});
|
||||
@@ -898,7 +838,7 @@ export const renderedDocsExtension: Extension = [
|
||||
flipped,
|
||||
docsField,
|
||||
passiveDocs,
|
||||
deferredScanner,
|
||||
passiveScanner,
|
||||
hovered,
|
||||
hoverTracker,
|
||||
showSourceAtCaret,
|
||||
|
||||
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
|
||||
] as MenuItem[])
|
||||
: ([
|
||||
{ kind: "separator" },
|
||||
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
|
||||
openInMenu(slot.model.path),
|
||||
{ kind: "separator" },
|
||||
localHistoryMenu(slot.model.path),
|
||||
gitMenu(slot.model.path),
|
||||
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
|
||||
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
|
||||
function openInMenu(path: string, line?: number): MenuItem {
|
||||
function openInMenu(path: string): MenuItem {
|
||||
return {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
@@ -1971,7 +1971,7 @@ function openInMenu(path: string, line?: number): MenuItem {
|
||||
: []),
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path], line }),
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { structure } from "@/editor/structure";
|
||||
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
|
||||
if (view.state.readOnly) return;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
hint("Cannot read the clipboard");
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings › Tools › Agents › Command Permissions: whitelist (auto-approve)
|
||||
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
|
||||
* and blacklist (auto-reject) command patterns evaluated on agent permission requests.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -47,18 +47,18 @@ export function AgentPolicyPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.body} aria-label="Command Permissions">
|
||||
<section className={styles.body} aria-label="Command Execution Policy">
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
Allowed commands run without asking, with or without extra arguments. Blocked commands are always rejected,
|
||||
even if allowed, and support * and ? wildcards. Commands with substitutions, redirections or variable
|
||||
assignments always ask.
|
||||
Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands
|
||||
with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win
|
||||
over allowed ones; they support the * and ? wildcards.
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Allowed Commands</T>
|
||||
<T>Allowed Commands (Whitelist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.allowedCommands.map((pattern) => (
|
||||
@@ -80,7 +80,7 @@ export function AgentPolicyPage() {
|
||||
value={allowDraft}
|
||||
onChange={(e) => setAllowDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAllowed()}
|
||||
placeholder={tx("e.g. cargo test")}
|
||||
placeholder={tx("e.g. cargo test*")}
|
||||
aria-label="Allowed command pattern"
|
||||
/>
|
||||
<Button size="small" aria-label="Add allowed command" onClick={addAllowed}>
|
||||
@@ -90,7 +90,7 @@ export function AgentPolicyPage() {
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Blocked Commands</T>
|
||||
<T>Blocked Commands (Blacklist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.blockedCommands.map((pattern) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ function FormWrapper({ children }: { children: ReactNode }) {
|
||||
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
|
||||
}
|
||||
|
||||
describe("Command Permissions sub-page", () => {
|
||||
describe("Command Execution Policy sub-page", () => {
|
||||
it("registers under Agents group in settings", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
try {
|
||||
@@ -40,7 +40,7 @@ describe("Command Permissions sub-page", () => {
|
||||
const agentsGroup = tree.find((n) => n.title === AGENTS_GROUP);
|
||||
expect(agentsGroup).toBeDefined();
|
||||
const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE);
|
||||
expect(policyNode?.title).toBe("Command Permissions");
|
||||
expect(policyNode?.title).toBe("Command Execution Policy");
|
||||
// A project's settings file can't decide what agents run without asking.
|
||||
expect(policyNode?.page?.projectScope).not.toBe(true);
|
||||
} finally {
|
||||
@@ -56,7 +56,7 @@ describe("Command Permissions sub-page", () => {
|
||||
</FormWrapper>,
|
||||
);
|
||||
|
||||
const policy = screen.getByRole("region", { name: "Command Permissions" });
|
||||
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
|
||||
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function activateSettingsPages(): () => void {
|
||||
}),
|
||||
registerSettingsPage({
|
||||
id: POLICY_PAGE,
|
||||
title: "Command Permissions",
|
||||
title: "Command Execution Policy",
|
||||
group: AGENTS_GROUP,
|
||||
order: 490,
|
||||
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
|
||||
|
||||
@@ -13,7 +13,7 @@ export const REGISTRY_PAGE = "agents.list";
|
||||
export const AGENTS_GROUP = "Agents";
|
||||
/** Settings › Agents › External MCP Servers: User and Workspace MCP configurations. */
|
||||
export const MCP_PAGE = "agents.mcpServers";
|
||||
/** Settings › Agents › Command Permissions. */
|
||||
/** Settings › Agents › Command Execution Policy. */
|
||||
export const POLICY_PAGE = "agents.policy";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { quitApp } from "./closeGuard";
|
||||
import { guessOs } from "./host";
|
||||
import { showKeymapReference } from "./KeymapReference";
|
||||
import { checkForUpdates } from "./updates";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
@@ -51,8 +50,8 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
|
||||
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
|
||||
}
|
||||
if (command === "selectAll" && selectReadingPane(document.activeElement)) return;
|
||||
if (command === "paste") {
|
||||
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
|
||||
if (command === "paste" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
}
|
||||
document.execCommand(command);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { VENDORS, vendorPreset } from "./vendors";
|
||||
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
|
||||
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
|
||||
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
const act = (
|
||||
action: string,
|
||||
@@ -49,7 +48,7 @@ function dataSourceItems(): MenuItem[] {
|
||||
async function sourceFromClipboard() {
|
||||
let text = "";
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// Unreadable clipboard: reported below like an unknown one.
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ import { gutterIconMarkup, showContextMenu } from "@/ui";
|
||||
import { emptyGutterSpacer, gutterSpacer } from "@/editor/api";
|
||||
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
|
||||
import { appendGeneratedRequests } from "./generatedRequests";
|
||||
import { headerCompletion } from "./headers";
|
||||
import { sourceReader } from "./spring/editor";
|
||||
import { findHost } from "./spring/request";
|
||||
import { METHODS } from "./language";
|
||||
import styles from "./HttpClient.module.css";
|
||||
|
||||
@@ -20,15 +17,13 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
|
||||
|
||||
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
|
||||
|
||||
export function renderEndpoint(e: EndpointEntry, host = "http://localhost:8080"): string {
|
||||
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} ${host}${endpointUrl(e.url)}\n`;
|
||||
export function renderEndpoint(e: EndpointEntry): string {
|
||||
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
|
||||
}
|
||||
|
||||
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
|
||||
try {
|
||||
// The server address of the endpoint's module (`server.port`, context path), as for Spring mappings.
|
||||
const host = await findHost(endpoint.path, useProject.getState().project?.root ?? null, sourceReader);
|
||||
await appendGeneratedRequests([renderEndpoint(endpoint, host)]);
|
||||
await appendGeneratedRequests([renderEndpoint(endpoint)]);
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
|
||||
}
|
||||
@@ -99,11 +94,7 @@ class EndpointMarker extends GutterMarker {
|
||||
const endpointSpacer = gutterSpacer(styles.runMarker ?? "");
|
||||
|
||||
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
|
||||
if (ctx.language === "http")
|
||||
return EditorState.languageData.of(() => [
|
||||
{ openideaCompletion: endpointCompletion },
|
||||
{ openideaCompletion: headerCompletion },
|
||||
]);
|
||||
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
|
||||
if (ctx.virtual) return [];
|
||||
return [
|
||||
marks,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { headerTarget } from "./headers";
|
||||
|
||||
const at = (text: string) => headerTarget(text.replace("|", ""), text.indexOf("|"));
|
||||
|
||||
describe("header completion", () => {
|
||||
it("completes header names on the lines after the request line", () => {
|
||||
expect(at("POST http://localhost:8080/users\nCon|")).toEqual({ kind: "name", from: 33, prefix: "Con" });
|
||||
expect(at("### Create\n# @name create\nPOST {{host}}/users\nAccept: */*\n|")).toMatchObject({
|
||||
kind: "name",
|
||||
prefix: "",
|
||||
});
|
||||
expect(at("http://localhost/a\nX-Re|")).toMatchObject({ kind: "name", prefix: "X-Re" });
|
||||
});
|
||||
|
||||
it("completes the values of a header", () => {
|
||||
expect(at("POST http://x/a\nContent-Type: app|")).toEqual({
|
||||
kind: "value",
|
||||
name: "Content-Type",
|
||||
from: 30,
|
||||
prefix: "app",
|
||||
});
|
||||
expect(at("GET http://x/a\nAuthorization:|")).toMatchObject({ kind: "value", name: "Authorization", prefix: "" });
|
||||
});
|
||||
|
||||
it("stays out of request lines, bodies, comments and blocks without a request", () => {
|
||||
expect(at("POS|")).toBeNull();
|
||||
expect(at('POST http://x/a\nContent-Type: application/json\n\n{"a|')).toBeNull();
|
||||
expect(at("POST http://x/a\n# Acc|")).toBeNull();
|
||||
expect(at("@host = http://x\nAcc|")).toBeNull();
|
||||
expect(at("POST http://x/a\n\n###\nAcc|")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/** Header name and value completion in `.http` request blocks (the lines after the request line). */
|
||||
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import { METHODS } from "./language";
|
||||
|
||||
const REQUEST_LINE = new RegExp(`^\\s*(?:(?:${METHODS.join("|")})\\s+\\S|https?://|\\{\\{)`);
|
||||
const COMMENT = /^\s*(?:#|\/\/)/;
|
||||
|
||||
const HEADERS = [
|
||||
"Accept",
|
||||
"Accept-Charset",
|
||||
"Accept-Encoding",
|
||||
"Accept-Language",
|
||||
"Access-Control-Request-Headers",
|
||||
"Access-Control-Request-Method",
|
||||
"Authorization",
|
||||
"Cache-Control",
|
||||
"Connection",
|
||||
"Content-Disposition",
|
||||
"Content-Encoding",
|
||||
"Content-Language",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
"Cookie",
|
||||
"Date",
|
||||
"Expect",
|
||||
"Forwarded",
|
||||
"From",
|
||||
"Host",
|
||||
"If-Match",
|
||||
"If-Modified-Since",
|
||||
"If-None-Match",
|
||||
"If-Range",
|
||||
"If-Unmodified-Since",
|
||||
"Max-Forwards",
|
||||
"Origin",
|
||||
"Pragma",
|
||||
"Proxy-Authorization",
|
||||
"Range",
|
||||
"Referer",
|
||||
"TE",
|
||||
"Upgrade",
|
||||
"User-Agent",
|
||||
"Via",
|
||||
"X-Correlation-ID",
|
||||
"X-CSRF-Token",
|
||||
"X-Forwarded-For",
|
||||
"X-Forwarded-Host",
|
||||
"X-Forwarded-Proto",
|
||||
"X-Request-ID",
|
||||
"X-Requested-With",
|
||||
];
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"text/xml",
|
||||
"text/csv",
|
||||
"application/octet-stream",
|
||||
"application/graphql",
|
||||
"application/x-ndjson",
|
||||
"application/problem+json",
|
||||
"*/*",
|
||||
];
|
||||
|
||||
const VALUES: Record<string, string[]> = {
|
||||
accept: MEDIA_TYPES,
|
||||
"content-type": MEDIA_TYPES,
|
||||
"accept-encoding": ["gzip, deflate, br", "gzip", "deflate", "br", "identity"],
|
||||
"content-encoding": ["gzip", "deflate", "br"],
|
||||
"accept-charset": ["utf-8", "iso-8859-1"],
|
||||
"accept-language": ["en-US", "en", "zh-CN", "zh"],
|
||||
authorization: ["Bearer ", "Basic "],
|
||||
"proxy-authorization": ["Bearer ", "Basic "],
|
||||
"cache-control": ["no-cache", "no-store", "max-age=0", "must-revalidate"],
|
||||
pragma: ["no-cache"],
|
||||
connection: ["keep-alive", "close", "Upgrade"],
|
||||
upgrade: ["websocket", "h2c"],
|
||||
expect: ["100-continue"],
|
||||
"x-requested-with": ["XMLHttpRequest"],
|
||||
"content-disposition": ["attachment", "inline", 'form-data; name=""'],
|
||||
"access-control-request-method": METHODS,
|
||||
};
|
||||
|
||||
export type HeaderTarget =
|
||||
| { kind: "name"; from: number; prefix: string }
|
||||
| { kind: "value"; name: string; from: number; prefix: string };
|
||||
|
||||
/** What is being typed at `pos`: a header name or a header's value, or null outside a request's headers. */
|
||||
export function headerTarget(text: string, pos: number): HeaderTarget | null {
|
||||
const before = text.slice(0, pos);
|
||||
const lineStart = before.lastIndexOf("\n") + 1;
|
||||
const line = before.slice(lineStart);
|
||||
if (COMMENT.test(line)) return null;
|
||||
const lines = (
|
||||
before
|
||||
.slice(0, lineStart)
|
||||
.split(/^\s*###.*$/m)
|
||||
.at(-1) ?? ""
|
||||
)
|
||||
.split("\n")
|
||||
.slice(0, -1);
|
||||
let request = false;
|
||||
for (const l of lines) {
|
||||
if (COMMENT.test(l)) continue;
|
||||
if (!request) request = REQUEST_LINE.test(l);
|
||||
// A blank line ends the headers; the body follows.
|
||||
else if (!l.trim()) return null;
|
||||
}
|
||||
if (!request) return null;
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) return /^[\w-]*$/.test(line) ? { kind: "name", from: lineStart, prefix: line } : null;
|
||||
const prefix = line.slice(colon + 1).replace(/^\s+/, "");
|
||||
return { kind: "value", name: line.slice(0, colon).trim(), from: pos - prefix.length, prefix };
|
||||
}
|
||||
|
||||
export function headerCompletion(context: CompletionContext): CompletionResult | null {
|
||||
const target = headerTarget(context.state.doc.toString(), context.pos);
|
||||
if (!target) return null;
|
||||
if (target.kind === "name") {
|
||||
if (!target.prefix && !context.explicit) return null;
|
||||
const options: Completion[] = HEADERS.map((name) => ({ label: name, type: "property", apply: `${name}: ` }));
|
||||
return { from: target.from, options, validFor: /^[\w-]*$/ };
|
||||
}
|
||||
const values = VALUES[target.name.toLowerCase()];
|
||||
if (!values) return null;
|
||||
return { from: target.from, options: values.map((v) => ({ label: v, type: "constant" })) };
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
|
||||
export const GENERATE_REQUEST = "http.generateSpringRequest";
|
||||
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
|
||||
|
||||
export const sourceReader: SourceReader = {
|
||||
const reader: SourceReader = {
|
||||
read: async (path) => {
|
||||
try {
|
||||
return await call(commands.fsReadText(path));
|
||||
@@ -29,7 +29,6 @@ export const sourceReader: SourceReader = {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
filesNamed: (name) => call(commands.findFilesNamed(name)),
|
||||
};
|
||||
|
||||
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
|
||||
@@ -44,12 +43,12 @@ export async function generateRequests(sourcePath: string, text: string, targets
|
||||
try {
|
||||
const root = useProject.getState().project?.root ?? null;
|
||||
const [host, lookup] = await Promise.all([
|
||||
findHost(sourcePath, root, sourceReader),
|
||||
findHost(sourcePath, root, reader),
|
||||
bodyLookup(
|
||||
sourcePath,
|
||||
parseSource(text),
|
||||
targets.map((t) => t.endpoint),
|
||||
sourceReader,
|
||||
reader,
|
||||
),
|
||||
]);
|
||||
const blocks = targets.map(({ endpoint, path }) =>
|
||||
|
||||
@@ -115,18 +115,9 @@ export function skeleton(type: string, lookup: (name: string) => ClassDecl | nul
|
||||
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
|
||||
const cls = lookup(t.replace(/<.*$/, ""));
|
||||
if (!cls || depth > 1) return {};
|
||||
// Superclass properties first, as Jackson writes them.
|
||||
const chain = [cls];
|
||||
for (let c = cls; c.superclass && chain.length <= MAX_SUPERCLASSES;) {
|
||||
const parent = lookup(bareType(c.superclass).replace(/<.*$/, ""));
|
||||
if (!parent || chain.includes(parent)) break;
|
||||
chain.unshift((c = parent));
|
||||
}
|
||||
const obj: { [k: string]: Json } = {};
|
||||
for (const c of chain) {
|
||||
for (const p of c.ctorParams) if (!(p.name in obj)) obj[p.name] = skeleton(p.type, lookup, depth + 1);
|
||||
for (const f of c.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
|
||||
}
|
||||
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
|
||||
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -184,52 +175,34 @@ export function appendRequests(existing: string, blocks: string[]): { insert: st
|
||||
export interface SourceReader {
|
||||
/** File text, null when missing. */
|
||||
read(path: string): Promise<string | null>;
|
||||
/** The project's files called `name` (from the index). */
|
||||
filesNamed?(name: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/** How many superclasses of a body type are followed for their fields. */
|
||||
const MAX_SUPERCLASSES = 5;
|
||||
|
||||
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
|
||||
|
||||
/** The Spring Boot configuration files (of `profile`) for `sourcePath` (its module's `src/main/resources`), most specific first. */
|
||||
export function configCandidates(sourcePath: string, projectRoot: string | null, profile?: string): string[] {
|
||||
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
|
||||
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
|
||||
const norm = sourcePath.replace(/\\/g, "/");
|
||||
const at = norm.lastIndexOf("/src/main/");
|
||||
const roots = at >= 0 ? [norm.slice(0, at)] : [];
|
||||
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
|
||||
return roots.flatMap((r) =>
|
||||
["properties", "yml", "yaml"].map((x) => `${r}/src/main/resources/application${profile ? `-${profile}` : ""}.${x}`),
|
||||
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
|
||||
const read = async (paths: string[]) => {
|
||||
const configs: Map<string, string>[] = [];
|
||||
for (const path of paths) {
|
||||
const text = await fs.read(path).catch(() => null);
|
||||
if (text !== null) configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
|
||||
}
|
||||
return configs;
|
||||
};
|
||||
const base = await read(configCandidates(sourcePath, projectRoot));
|
||||
// `spring.profiles.active=dev`: `application-dev.*` overrides the base files (the last profile wins).
|
||||
const active = base.map((c) => resolved(c.get("spring.profiles.active"))).find(Boolean);
|
||||
const profiles = (active ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => /^[\w.-]+$/.test(p))
|
||||
.reverse();
|
||||
const profiled = await read(profiles.flatMap((p) => configCandidates(sourcePath, projectRoot, p)));
|
||||
return hostFromConfig([...profiled, ...base]);
|
||||
const configs: Map<string, string>[] = [];
|
||||
for (const path of configCandidates(sourcePath, projectRoot)) {
|
||||
const text = await fs.read(path).catch(() => null);
|
||||
if (text === null) continue;
|
||||
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
|
||||
}
|
||||
return hostFromConfig(configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body types of `endpoints`: classes of the controller's file, then `<Type>.java|.kt` beside
|
||||
* it or where its import points (same source root), else the project file of that name in the
|
||||
* imported (or same / wildcard-imported) package, e.g. a DTO in another Maven module. Their
|
||||
* superclasses are loaded too.
|
||||
* Body types of `endpoints`: classes of the controller's file, then
|
||||
* `<Type>.java|.kt` beside it or where its import points (same source root).
|
||||
*/
|
||||
export async function bodyLookup(
|
||||
sourcePath: string,
|
||||
@@ -238,78 +211,42 @@ export async function bodyLookup(
|
||||
fs: SourceReader,
|
||||
): Promise<(name: string) => ClassDecl | null> {
|
||||
const known = new Map<string, ClassDecl>();
|
||||
/** Where each class was read: names in its declarations resolve from that file. */
|
||||
const origins = new Map<string, Origin>();
|
||||
const add = (o: Origin) => {
|
||||
for (const c of allClasses(o.file))
|
||||
if (!known.has(c.name)) {
|
||||
known.set(c.name, c);
|
||||
origins.set(c.name, o);
|
||||
}
|
||||
const add = (f: SourceFile) => {
|
||||
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
|
||||
};
|
||||
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
|
||||
add(origin);
|
||||
add(file);
|
||||
const sourceRoot = (path: string, f: SourceFile) => {
|
||||
let root = dirname(path);
|
||||
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
|
||||
return root;
|
||||
};
|
||||
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
|
||||
const readAt = async (path: string) => {
|
||||
const text = await fs.read(path).catch(() => null);
|
||||
if (text === null) return null;
|
||||
const loaded = { path, file: parseSource(text) };
|
||||
add(loaded);
|
||||
return loaded;
|
||||
};
|
||||
/** Loads `name` as seen from `from` (its imports, then its package). */
|
||||
const load = async (name: string, from: Origin) => {
|
||||
if (known.has(name)) return origins.get(name) ?? null;
|
||||
if (!/^[A-Z]\w*$/.test(name)) return null;
|
||||
/** Loads `name` as seen from `from` (its imports, then its directory). */
|
||||
const load = async (name: string, from: { path: string; file: SourceFile }) => {
|
||||
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
|
||||
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
|
||||
const packages = imported
|
||||
? [imported.split(".").slice(0, -1).join(".")]
|
||||
: [from.file.packageName, ...from.file.imports.filter((i) => i.endsWith(".*")).map((i) => i.slice(0, -2))];
|
||||
const root = sourceRoot(from.path, from.file);
|
||||
const dirs = new Set([...packages.map((p) => (p ? `${root}/${p.replace(/\./g, "/")}` : root)), dirname(from.path)]);
|
||||
for (const d of dirs)
|
||||
const dir = dirname(from.path);
|
||||
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
|
||||
for (const d of pkgDir ? [pkgDir, dir] : [dir])
|
||||
for (const x of ext) {
|
||||
const loaded = await readAt(`${d}/${name}${x}`);
|
||||
if (loaded) return loaded;
|
||||
const path = `${d}/${name}${x}`;
|
||||
const text = await fs.read(path).catch(() => null);
|
||||
if (text === null) continue;
|
||||
const loaded = parseSource(text);
|
||||
add(loaded);
|
||||
return { path, file: loaded };
|
||||
}
|
||||
// Another source root or module: the project's files of that name, in one of those packages.
|
||||
if (!fs.filesNamed) return null;
|
||||
const suffixes = packages.map((p) => `/${p ? `${p.replace(/\./g, "/")}/` : ""}${name}`);
|
||||
for (const x of ext) {
|
||||
const paths = (await fs.filesNamed(`${name}${x}`).catch(() => [])).map((p) => p.replace(/\\/g, "/"));
|
||||
const path = paths.find((p) => suffixes.some((s) => p.endsWith(`${s}${x}`)));
|
||||
const loaded = path ? await readAt(path) : null;
|
||||
if (loaded) return loaded;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
|
||||
/** `name`, its superclasses, and the property types of each, one level down. */
|
||||
const loadWithProperties = async (name: string, from: Origin, depth: number) => {
|
||||
const seen = new Set<string>();
|
||||
let [current, at]: [string | undefined, Origin | null] = [name, from];
|
||||
while (current && at && !seen.has(current) && seen.size < MAX_SUPERCLASSES) {
|
||||
seen.add(current);
|
||||
const loaded = await load(current, at);
|
||||
const c = known.get(current);
|
||||
if (!c || !loaded) break;
|
||||
if (depth > 0)
|
||||
for (const p of [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)])
|
||||
await loadWithProperties(typeName(p.type), loaded, depth - 1);
|
||||
[current, at] = [c.superclass && typeName(c.superclass), loaded];
|
||||
}
|
||||
};
|
||||
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
|
||||
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
|
||||
for (const n of names) await loadWithProperties(n, origin, 1);
|
||||
for (const n of names) {
|
||||
const from = (await load(n, origin)) ?? origin;
|
||||
// Property types one level down, as seen from the body type's file.
|
||||
const c = known.get(n);
|
||||
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
|
||||
await load(typeName(p.type), from);
|
||||
}
|
||||
return (name) => known.get(name) ?? null;
|
||||
}
|
||||
|
||||
interface Origin {
|
||||
path: string;
|
||||
file: SourceFile;
|
||||
}
|
||||
@@ -65,8 +65,6 @@ export interface ClassDecl {
|
||||
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
|
||||
ctorParams: Param[];
|
||||
classes: ClassDecl[];
|
||||
/** The class it extends (`extends Base<T>` / Kotlin `: Base()`), as written. */
|
||||
superclass?: string;
|
||||
}
|
||||
|
||||
export interface SourceFile {
|
||||
@@ -222,26 +220,6 @@ class Reader {
|
||||
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
|
||||
}
|
||||
|
||||
/** A type name at `i` (`a.b.Base<T>`): its text and the index after it. */
|
||||
typeName(i: number, to: number): { name: string; end: number } | null {
|
||||
let j = i;
|
||||
while (j < to && this.toks[j]?.kind === "id" && (j === i || this.is(j - 1, "."))) {
|
||||
j++;
|
||||
if (!this.is(j, ".") || this.toks[j + 1]?.kind !== "id") break;
|
||||
j++;
|
||||
}
|
||||
if (j === i) return null;
|
||||
if (this.is(j, "<")) {
|
||||
let depth = 0;
|
||||
for (; j < to; j++) {
|
||||
if (this.is(j, "<")) depth++;
|
||||
else if (this.is(j, ">") && --depth === 0) break;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
return { name: joined(this.toks.slice(i, j)), end: j };
|
||||
}
|
||||
|
||||
/** Kotlin: a token on a new line that starts a member ends the previous one. */
|
||||
startsMember(i: number): boolean {
|
||||
const t = this.toks[i];
|
||||
@@ -437,12 +415,6 @@ class Reader {
|
||||
}
|
||||
for (; j < to; j++) {
|
||||
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
|
||||
// Java `extends Base`; Kotlin `: Base(…)` (a supertype called with arguments is the class).
|
||||
const javaSuper = this.toks[j]!.kind === "id" && this.toks[j]!.text === "extends";
|
||||
if (!cls.superclass && (javaSuper || this.is(j, ":") || this.is(j, ","))) {
|
||||
const type = this.typeName(j + 1, to);
|
||||
if (type && (javaSuper || this.is(type.end, "("))) cls.superclass = type.name;
|
||||
}
|
||||
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
|
||||
}
|
||||
if (this.is(j, "{")) {
|
||||
|
||||
@@ -252,19 +252,6 @@ describe("server address", () => {
|
||||
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
|
||||
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
|
||||
});
|
||||
|
||||
it("reads the active profiles' files over the base ones", async () => {
|
||||
const files: Record<string, string> = {
|
||||
"/ws/api/src/main/resources/application.yml":
|
||||
"server:\n port: 8001\n servlet:\n context-path: /api\nspring:\n profiles:\n active: ${PROFILE:local}, dev\n",
|
||||
"/ws/api/src/main/resources/application-local.properties": "server.port=7000\n",
|
||||
"/ws/api/src/main/resources/application-dev.yml": "server:\n port: 9100\n",
|
||||
};
|
||||
const fs: SourceReader = { read: async (p) => files[p] ?? null };
|
||||
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:9100/api");
|
||||
delete files["/ws/api/src/main/resources/application-dev.yml"];
|
||||
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:7000/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("body types from other files", () => {
|
||||
@@ -284,41 +271,6 @@ describe("body types from other files", () => {
|
||||
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
|
||||
expect(skeleton("Unknown", lookup)).toEqual({});
|
||||
});
|
||||
|
||||
it("finds a DTO in another module through the index, with its superclass fields", async () => {
|
||||
const files: Record<string, string> = {
|
||||
"/ws/common/src/main/java/com/example/demo/dto/CreateUser.java":
|
||||
"package com.example.demo.dto;\nimport com.example.base.BaseEntity;\n@Data\npublic class CreateUser extends BaseEntity<Long> implements Serializable {\n private String name;\n private Address address;\n}\n",
|
||||
"/ws/common/src/main/java/com/example/demo/dto/Address.java":
|
||||
"package com.example.demo.dto;\npublic class Address { private String city; }\n",
|
||||
"/ws/base/src/main/java/com/example/base/BaseEntity.java":
|
||||
"package com.example.base;\npublic abstract class BaseEntity<ID> { private ID id; private LocalDateTime createdAt; }\n",
|
||||
// Same name, other package: not the imported class.
|
||||
"/ws/legacy/src/main/java/com/legacy/CreateUser.java":
|
||||
"package com.legacy;\npublic class CreateUser { int old; }\n",
|
||||
};
|
||||
const fs: SourceReader = {
|
||||
read: async (p) => files[p] ?? null,
|
||||
filesNamed: async (name) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
|
||||
};
|
||||
const eps = springControllers(JAVA)[0]!.endpoints;
|
||||
const lookup = await bodyLookup(
|
||||
"/ws/web/src/main/java/com/example/demo/web/UserController.java",
|
||||
parseSource(JAVA),
|
||||
eps,
|
||||
fs,
|
||||
);
|
||||
expect(skeleton("CreateUser", lookup)).toEqual({ id: {}, createdAt: "", name: "", address: { city: "" } });
|
||||
});
|
||||
|
||||
it("reads Java and Kotlin superclasses", () => {
|
||||
const java = parseSource("class A extends com.x.Base<T> implements I { }\ninterface J extends K {}");
|
||||
expect(java.classes.map((c) => c.superclass)).toEqual(["com.x.Base<T>", "K"]);
|
||||
const kotlin = parseSource(
|
||||
"class A(val a: Int) : Base(a), Serializable\nclass B : Serializable, Other()\nclass C : I",
|
||||
);
|
||||
expect(kotlin.classes.map((c) => c.superclass)).toEqual(["Base", "Other", undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appending to the request file", () => {
|
||||
|
||||
@@ -83,14 +83,6 @@ export function touchesManifest(paths: readonly string[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's order, whatever order the ecosystems were detected in: SDKs first, then libraries, each by name. */
|
||||
function sortedGroups(groups: readonly LibraryGroup[]): LibraryGroup[] {
|
||||
const rank = (g: LibraryGroup) => (SDK_KINDS.has(g.kind) ? 0 : 1);
|
||||
return [...groups].sort(
|
||||
(a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The "External Libraries" node for `groups` (`< Python 3.12 (.venv) >` › `python3.12`, `site-packages`). */
|
||||
export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootContribution | null {
|
||||
if (!groups.length) return null;
|
||||
@@ -101,7 +93,7 @@ export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootC
|
||||
// After the project, before Scratches and Consoles (IDEA's order).
|
||||
order: 5,
|
||||
readOnly: true,
|
||||
children: sortedGroups(groups).map((g) => ({
|
||||
children: groups.map((g) => ({
|
||||
label: SDK_KINDS.has(g.kind) ? `< ${g.name} >` : g.name,
|
||||
icon: GROUP_ICONS[g.kind] ?? "library",
|
||||
children: g.roots.map((r) => ({
|
||||
@@ -200,18 +192,12 @@ export function activate(): () => void {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
|
||||
});
|
||||
// A Maven / Gradle sync changes the dependency versions without touching a manifest.
|
||||
const unsubRoots = onIdeEvent("projectRootsChanged", () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
|
||||
});
|
||||
return () => {
|
||||
seq++;
|
||||
if (request !== undefined) void commands.cancelRequest(request).catch(() => {});
|
||||
clearTimeout(timer);
|
||||
unsub();
|
||||
unsubFs();
|
||||
unsubRoots();
|
||||
disposeActions();
|
||||
clear();
|
||||
};
|
||||
|
||||
@@ -37,46 +37,18 @@ describe("External Libraries", () => {
|
||||
}),
|
||||
),
|
||||
)!;
|
||||
expect(Object.fromEntries(root.children!.map((g) => [g.label, g.icon]))).toEqual({
|
||||
"< jdk >": "file-java",
|
||||
nodeCore: "file-js",
|
||||
nodeModules: "file-npm",
|
||||
"< rust >": "file-rust",
|
||||
cargo: "file-cargo",
|
||||
"< go >": "file-go",
|
||||
goModules: "file-gomod",
|
||||
maven: "maven-project",
|
||||
gradle: "file-gradle",
|
||||
other: "library",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists SDKs first, then libraries by name, whatever order they were detected in", () => {
|
||||
const group = (name: string, kind: string) => ({ name, kind, roots: [{ name: "r", path: `/libs/${name}` }] });
|
||||
const detected = [
|
||||
group("Maven: org.junit:junit:4.13", "maven"),
|
||||
group("temurin-21", "jdk"),
|
||||
group("node_modules", "nodeModules"),
|
||||
group("Python 3.14", "python"),
|
||||
group("Cargo dependencies", "cargo"),
|
||||
group("Gradle: com.acme:lib:1.0", "gradle"),
|
||||
group("Go SDK 1.27.4", "go"),
|
||||
group("Maven: com.acme:core:2.1.27", "maven"),
|
||||
group("Node.js Core", "nodeCore"),
|
||||
];
|
||||
const labels = (groups: typeof detected) => librariesRoot(groups)!.children!.map((g) => g.label);
|
||||
expect(labels(detected)).toEqual([
|
||||
"< Go SDK 1.27.4 >",
|
||||
"< Python 3.14 >",
|
||||
"< temurin-21 >",
|
||||
"Cargo dependencies",
|
||||
"Gradle: com.acme:lib:1.0",
|
||||
"Maven: com.acme:core:2.1.27",
|
||||
"Maven: org.junit:junit:4.13",
|
||||
"node_modules",
|
||||
"Node.js Core",
|
||||
expect(root.children?.map((g) => g.icon)).toEqual([
|
||||
"file-java",
|
||||
"file-js",
|
||||
"file-npm",
|
||||
"file-rust",
|
||||
"file-cargo",
|
||||
"file-go",
|
||||
"file-gomod",
|
||||
"maven-project",
|
||||
"file-gradle",
|
||||
"library",
|
||||
]);
|
||||
expect(labels([...detected].reverse())).toEqual(labels(detected));
|
||||
});
|
||||
|
||||
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
respond: vi.fn(async () => ({ status: "ok", data: null })),
|
||||
open: vi.fn(),
|
||||
execute: vi.fn(async (_id: string, _arg?: unknown) => {}),
|
||||
}));
|
||||
vi.mock("@/platform/actions", async (original) => ({
|
||||
...(await original<object>()),
|
||||
executeActionOrThrow: mocks.execute,
|
||||
}));
|
||||
const mocks = vi.hoisted(() => ({ respond: vi.fn(async () => ({ status: "ok", data: null })), open: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<object>()),
|
||||
commands: { mcpRespond: mocks.respond },
|
||||
@@ -83,18 +75,4 @@ describe("MCP confirmations", () => {
|
||||
await handleMcpRequest(8, { type: "openFile", path: "/project/b.ts", line: null, column: null });
|
||||
expect(mocks.respond).toHaveBeenCalledWith(8, false, "Error: cannot open");
|
||||
});
|
||||
it("starts an agent command in the Run tool window and says whether it started", async () => {
|
||||
const request = { type: "agentRun", name: "_agent_test", command: "npm test", cwd: "/project", env: {} } as const;
|
||||
await handleMcpRequest(9, request);
|
||||
expect(mocks.execute).toHaveBeenCalledWith("run.agentStart", {
|
||||
name: "_agent_test",
|
||||
command: "npm test",
|
||||
cwd: "/project",
|
||||
env: {},
|
||||
});
|
||||
expect(mocks.respond).toHaveBeenCalledWith(9, true, null);
|
||||
mocks.execute.mockRejectedValueOnce(new Error("The command did not start"));
|
||||
await handleMcpRequest(10, request);
|
||||
expect(mocks.respond).toHaveBeenCalledWith(10, false, "The command did not start");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Window requests from the built-in MCP server, answered through window-scoped IPC. */
|
||||
import { create } from "zustand";
|
||||
import { executeActionOrThrow } from "@/platform/actions";
|
||||
import { call, commands, type McpUiRequest } from "@/platform/ipc";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
|
||||
@@ -44,17 +43,6 @@ export async function handleMcpRequest(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.type === "agentRun") {
|
||||
// The Run tool window starts it in the agent tab; the answer says whether it started.
|
||||
try {
|
||||
const { name, command, cwd, env } = request;
|
||||
await executeActionOrThrow("run.agentStart", { name, command, cwd, env });
|
||||
await send(id, true);
|
||||
} catch (error) {
|
||||
await send(id, false, error instanceof Error ? error.message : String(error)).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await editor().openFile(request.path, {
|
||||
selection: request.line != null ? { line: request.line - 1, col: (request.column ?? 1) - 1 } : undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
|
||||
@@ -94,7 +94,7 @@ export function EnvDialog({
|
||||
};
|
||||
const paste = async () => {
|
||||
try {
|
||||
const text = await readClipboardPlainText();
|
||||
const text = await navigator.clipboard.readText();
|
||||
setRows((r) => pasteRows(r, text));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
|
||||
@@ -12,32 +12,6 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Archived agent tabs (`run_command`), after the run tabs. */
|
||||
.agentArchive {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
margin: 0 4px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.agentArchive:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.agentArchive[data-active] {
|
||||
color: var(--accent);
|
||||
}
|
||||
.agentArchiveCount {
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import type { RunTargetValid } from "@/platform/remoteSettings";
|
||||
import { useAgentRunSettings, useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { EmptyState, Icon, IconButton, Menu, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
|
||||
import { ConsoleModel } from "./console";
|
||||
@@ -39,8 +39,6 @@ import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab }
|
||||
import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate";
|
||||
import { isUpdatable } from "./updatePolicies";
|
||||
import { configIcon } from "./configs";
|
||||
import { agentName } from "./agentConfig";
|
||||
import { archivedAgentSessions, closeAgentArchive } from "./agentRuns";
|
||||
import styles from "./Run.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
@@ -275,8 +273,6 @@ function sessionItems(sessions: readonly RunSession[], targets: readonly RunTarg
|
||||
text: remoteLabel(s.config, targets),
|
||||
icon: sessionIcon(s),
|
||||
color: failed(s) ? "var(--danger)" : undefined,
|
||||
// An agent's command that ended, however it ended.
|
||||
dimmed: agentName(s.config) !== null && !isLive(s),
|
||||
closable: true,
|
||||
tooltip:
|
||||
s.status === "finished"
|
||||
@@ -312,74 +308,26 @@ export function RunTabs() {
|
||||
const traces = useStackTraces((s) => s.tabs);
|
||||
const runTargets = useWorkflowSettings((s) => s.runTargets);
|
||||
const active = useRun((s) => s.activeSession);
|
||||
const { tabLimit } = useAgentRunSettings();
|
||||
const current = currentTab(sessions, traces, active);
|
||||
if (!current) return null;
|
||||
const archived = archivedAgentSessions(sessions, tabLimit);
|
||||
const shown = archived.length ? sessions.filter((s) => !archived.includes(s)) : sessions;
|
||||
const items: TabItem[] = [
|
||||
...sessionItems(shown, runTargets),
|
||||
...sessionItems(sessions, runTargets),
|
||||
...traces.map((t) => ({ id: t.id, label: t.title, text: t.title, closable: true })),
|
||||
];
|
||||
const activeId = "session" in current ? current.session.id : current.trace.id;
|
||||
return (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.tabs}
|
||||
aria-label="Run sessions"
|
||||
items={items}
|
||||
activeId={activeId}
|
||||
activeId={"session" in current ? current.session.id : current.trace.id}
|
||||
onSelect={(id) => useRun.setState({ activeSession: id })}
|
||||
onClose={(id) => void closeRunTab(id, traces)}
|
||||
panelIdPrefix="openidea-run"
|
||||
trailing={archived.length > 0 && <AgentArchive sessions={archived} activeId={activeId} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The folder of archived agent tabs at the end of the strip, with their number. */
|
||||
function AgentArchive({ sessions, activeId }: { sessions: RunSession[]; activeId: string }) {
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const holdsActive = sessions.some((s) => s.id === activeId);
|
||||
const count = sessions.length;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchor}
|
||||
type="button"
|
||||
className={styles.agentArchive}
|
||||
data-active={holdsActive || undefined}
|
||||
aria-label={tx`Archived agent tabs: ${count}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="folder" size={14} />
|
||||
<span className={styles.agentArchiveCount}>{tx`Archived (${count})`}</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label="Archived agent tabs"
|
||||
items={[
|
||||
...sessions.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.config.name,
|
||||
verbatim: true,
|
||||
icon: sessionIcon(s),
|
||||
checked: s.id === activeId,
|
||||
run: () => useRun.setState({ activeSession: s.id }),
|
||||
})),
|
||||
{ kind: "separator" as const },
|
||||
{ label: "Close All Archived Tabs", run: () => closeAgentArchive() },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunView() {
|
||||
const sessions = useRun((s) => s.sessions);
|
||||
const traces = useStackTraces((s) => s.tabs);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, isTauri, type RunConfig, type TomcatInstallation, type WebArtifact } from "@/platform/ipc";
|
||||
@@ -899,7 +899,7 @@ export function TomcatSettings({ config, onChange }: { config: RunConfig; onChan
|
||||
iconSize={13}
|
||||
title="Paste"
|
||||
onClick={() => {
|
||||
void readClipboardPlainText().then((text) => {
|
||||
void navigator.clipboard?.readText().then((text) => {
|
||||
if (!text.trim()) return;
|
||||
const idx = text.indexOf("=");
|
||||
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/** Run configurations of commands agents run through the IDE's MCP server (`run_command`). */
|
||||
import type { IpcPath, RunConfig } from "@/bindings";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
|
||||
/** Names the agent tab a run belongs to; the backend logs it for the agent. */
|
||||
export const AGENT_PARAM = "mcp.agentRun";
|
||||
/** Settings › Tools › Terminal › Shell path, which the backend runs the command line with. */
|
||||
const SHELL_PARAM = "mcp.agentShell";
|
||||
|
||||
/** The agent tab name of a configuration, or `null` for any other run. */
|
||||
export function agentName(config: Pick<RunConfig, "params">): string | null {
|
||||
return config.params?.[AGENT_PARAM] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command line of agent tab `name`, with the shell of the IDE's terminal but on pipes (as
|
||||
* JetBrains' `execute_terminal_command`): programs print plain lines, the console shows them and
|
||||
* sends the user's input lines.
|
||||
*/
|
||||
export function agentConfig(name: string, command: string, cwd: IpcPath, env: Record<string, string>): RunConfig {
|
||||
const terminal = workflowSettings().terminal;
|
||||
return {
|
||||
id: `mcp-agent:${name}`,
|
||||
name,
|
||||
kind: "shell",
|
||||
command,
|
||||
args: [],
|
||||
cwd,
|
||||
env: { ...terminal.env, ...env },
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
allowParallel: true,
|
||||
params: { [AGENT_PARAM]: name, [SHELL_PARAM]: terminal.shellPath },
|
||||
// This machine: an agent's command never goes to a default run target.
|
||||
target: "",
|
||||
};
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { agentConfig, agentName } from "./agentConfig";
|
||||
import { archivedAgentSessions } from "./agentRuns";
|
||||
import type { RunSession } from "./store";
|
||||
|
||||
const plain: RunConfig = {
|
||||
id: "npm:dev",
|
||||
name: "dev",
|
||||
kind: "npm",
|
||||
command: "npm",
|
||||
args: ["run", "dev"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
};
|
||||
|
||||
function session(id: string, config: RunConfig, status: RunSession["status"], endedAt: number | null): RunSession {
|
||||
return { id, config, runId: null, status, exitCode: 0, startedAt: 0, endedAt, stdinClosed: false };
|
||||
}
|
||||
|
||||
describe("agent tabs", () => {
|
||||
it("run the command line on pipes, on this machine", () => {
|
||||
const config = agentConfig("_agent_build", "cargo build", "/project", { A: "1" });
|
||||
expect(agentName(config)).toBe("_agent_build");
|
||||
expect(agentName(plain)).toBeNull();
|
||||
expect(config).toMatchObject({
|
||||
id: "mcp-agent:_agent_build",
|
||||
name: "_agent_build",
|
||||
command: "cargo build",
|
||||
args: [],
|
||||
cwd: "/project",
|
||||
target: "",
|
||||
allowParallel: true,
|
||||
});
|
||||
expect(config.emulateTerminal).toBeFalsy();
|
||||
expect(config.env).toMatchObject({ A: "1" });
|
||||
expect(config.params).toHaveProperty("mcp.agentShell");
|
||||
});
|
||||
|
||||
it("archive the oldest finished ones beyond the limit, never a running one or another run", () => {
|
||||
const agent = (name: string) => agentConfig(name, "true", "/p", {});
|
||||
const sessions = [
|
||||
session("old", agent("_agent_old"), "finished", 1),
|
||||
session("failed", agent("_agent_failed"), "failed", 2),
|
||||
session("live", agent("_agent_live"), "running", null),
|
||||
session("new", agent("_agent_new"), "finished", 3),
|
||||
session("user", plain, "finished", 0),
|
||||
];
|
||||
expect(archivedAgentSessions(sessions, 1).map((s) => s.id)).toEqual(["failed", "old"]);
|
||||
expect(archivedAgentSessions(sessions, 3)).toEqual([]);
|
||||
expect(archivedAgentSessions(sessions, 0).map((s) => s.id)).toEqual(["new", "failed", "old"]);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/** The Run tool window's agent tabs: starting them, and archiving finished ones. */
|
||||
import type { IpcPath } from "@/bindings";
|
||||
import { agentRunSettings } from "@/platform/workflowSettings";
|
||||
import { agentConfig, agentName } from "./agentConfig";
|
||||
import { closeSession, isLive, startRun, useRun, type RunSession } from "./store";
|
||||
|
||||
export interface AgentRunRequest {
|
||||
name: string;
|
||||
command: string;
|
||||
cwd: IpcPath;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Starts an agent's command in its tab (reused once its last command finished). */
|
||||
export async function startAgentRun(request: AgentRunRequest): Promise<void> {
|
||||
const config = agentConfig(request.name, request.command, request.cwd, request.env);
|
||||
const existing = useRun.getState().sessions.find((s) => s.config.id === config.id);
|
||||
// The backend refuses a running name first; nothing here stops a run on the agent's behalf.
|
||||
if (existing && isLive(existing)) throw new Error(`${request.name} is still running`);
|
||||
const session = await startRun(config, existing?.id);
|
||||
if (!session || session.status === "failed") throw new Error("The command did not start");
|
||||
}
|
||||
|
||||
const endedAt = (s: RunSession) => s.endedAt ?? s.startedAt;
|
||||
|
||||
/** Finished agent tabs beyond the newest `limit`: the archive, newest first. Running ones never are. */
|
||||
export function archivedAgentSessions(sessions: readonly RunSession[], limit: number): RunSession[] {
|
||||
return sessions
|
||||
.filter((s) => agentName(s.config) !== null && !isLive(s))
|
||||
.sort((a, b) => endedAt(b) - endedAt(a))
|
||||
.slice(Math.max(0, limit));
|
||||
}
|
||||
|
||||
/** Closes the oldest archived agent tabs beyond the archive limit. */
|
||||
export function trimAgentArchive() {
|
||||
const { tabLimit, archiveLimit } = agentRunSettings();
|
||||
const archived = archivedAgentSessions(useRun.getState().sessions, tabLimit);
|
||||
for (const s of archived.slice(archiveLimit)) closeSession(s.id, { agentTabClosed: false });
|
||||
}
|
||||
|
||||
/** Closes every archived agent tab. */
|
||||
export function closeAgentArchive() {
|
||||
const archived = archivedAgentSessions(useRun.getState().sessions, agentRunSettings().tabLimit);
|
||||
for (const s of archived) closeSession(s.id);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { executeAction, executeActionOrThrow, getAction, registerActions } from
|
||||
import { getContext, setContext, useContextKeys } from "@/platform/context";
|
||||
import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
|
||||
import {
|
||||
menuGroupItems,
|
||||
@@ -94,7 +94,6 @@ import {
|
||||
isLive,
|
||||
} from "./store";
|
||||
import { rerunFailedConfigs } from "./testing/rerun";
|
||||
import { startAgentRun, trimAgentArchive, type AgentRunRequest } from "./agentRuns";
|
||||
import { compoundTargets, selectedTarget } from "./sessionTargets";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -637,16 +636,6 @@ export function activate(): () => void {
|
||||
return loadConfigs();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "run.agentStart",
|
||||
title: "Run Agent Command",
|
||||
description:
|
||||
"Runs `{ name, command, cwd, env }` from the MCP server's `run_command` in the agent tab `name`; throws when it did not start.",
|
||||
category: "Run",
|
||||
hidden: true,
|
||||
when: "projectOpen",
|
||||
run: ({ arg }) => startAgentRun(arg as AgentRunRequest),
|
||||
},
|
||||
{
|
||||
id: "run.pluginStart",
|
||||
title: "Run Plugin Configuration",
|
||||
@@ -747,11 +736,6 @@ export function activate(): () => void {
|
||||
setContext("run.cmakeBuild", cmakeBuildId(cmake, s.configs));
|
||||
setContext("run.updatableRunning", updatableSessions().length > 0);
|
||||
}),
|
||||
// Agent tabs (`run_command`): the archive keeps at most Settings' number of them.
|
||||
useRun.subscribe((s, prev) => {
|
||||
if (s.sessions !== prev.sessions) trimAgentArchive();
|
||||
}),
|
||||
onWorkflowSettingsChange(() => agentRunSettings(), trimAgentArchive),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
|
||||
resetRuns();
|
||||
|
||||
@@ -16,7 +16,6 @@ import { findOutputLinks } from "./links";
|
||||
import { useRun } from "./store";
|
||||
import styles from "./StackTrace.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface StackTraceTab {
|
||||
id: string;
|
||||
@@ -84,7 +83,7 @@ export function closeStackTrace(id: string) {
|
||||
async function openDialog() {
|
||||
let clip = "";
|
||||
try {
|
||||
clip = await readClipboardPlainText();
|
||||
clip = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// clipboard unavailable or denied: start empty
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ import { compoundChildren } from "./configs";
|
||||
import { remoteLabel, startWithRemedies } from "./remote";
|
||||
import { TerminalOutput } from "./terminalOutput";
|
||||
import { typeFor } from "./types";
|
||||
import { agentName } from "./agentConfig";
|
||||
import { loadRunTemplates } from "./runTemplates";
|
||||
import { startedPort } from "@/platform/serverPort";
|
||||
import { tomcatBrowserUrl } from "./tomcat";
|
||||
@@ -1474,17 +1473,10 @@ export async function closeSessionTab(sessionId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a session's tab (stopping its process). An agent tab's closing is told to the MCP
|
||||
* server for the agent unless `agentTabClosed` is false (the archive dropping its oldest).
|
||||
*/
|
||||
export function closeSession(sessionId: string, options: { agentTabClosed?: boolean } = {}) {
|
||||
export function closeSession(sessionId: string) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) return;
|
||||
if (isLive(session)) void stopSession(sessionId);
|
||||
const agent = agentName(session.config);
|
||||
if (agent !== null && options.agentTabClosed !== false && isTauri())
|
||||
void call(commands.mcpAgentRunClosed(agent)).catch(() => {});
|
||||
const rt = runtimes.get(sessionId);
|
||||
if (rt) cancelFlush(rt);
|
||||
runtimes.delete(sessionId);
|
||||
|
||||
@@ -18,13 +18,7 @@ import {
|
||||
type ProjectStructureValid,
|
||||
} from "@/platform/languageSettings";
|
||||
import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings";
|
||||
import {
|
||||
DEFAULT_AGENT_RUNS,
|
||||
DEFAULT_WORKFLOW,
|
||||
validateAgentRuns,
|
||||
validateWorkflow,
|
||||
type WorkflowSettings,
|
||||
} from "@/platform/workflowSettings";
|
||||
import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { DEFAULT_INSPECTIONS, validateInspections, type InspectionSettingsValid } from "@/platform/inspections";
|
||||
import { DEFAULT_PROXY, validateProxy, type ProxySettingsValid } from "@/platform/proxySettings";
|
||||
import {
|
||||
@@ -112,7 +106,7 @@ export interface ValidSettings extends WorkflowSettings {
|
||||
/** Tools › Agents › Chat: the meters in an agent task's header. */
|
||||
agents: Required<AgentSettings>;
|
||||
/** Agents › IDE MCP Server (global). */
|
||||
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean; agentTabLimit: number; agentArchiveLimit: number };
|
||||
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean };
|
||||
}
|
||||
|
||||
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
|
||||
@@ -203,12 +197,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
defaults: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
mcpServer: {
|
||||
enabled: false,
|
||||
runWithoutConfirmation: false,
|
||||
agentTabLimit: DEFAULT_AGENT_RUNS.tabLimit,
|
||||
agentArchiveLimit: DEFAULT_AGENT_RUNS.archiveLimit,
|
||||
},
|
||||
mcpServer: { enabled: false, runWithoutConfirmation: false },
|
||||
...DEFAULT_WORKFLOW,
|
||||
};
|
||||
|
||||
@@ -381,8 +370,6 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
mcpServer: {
|
||||
enabled: bool(obj(root.mcpServer).enabled, d.mcpServer.enabled),
|
||||
runWithoutConfirmation: bool(obj(root.mcpServer).runWithoutConfirmation, d.mcpServer.runWithoutConfirmation),
|
||||
agentTabLimit: validateAgentRuns(root.mcpServer).tabLimit,
|
||||
agentArchiveLimit: validateAgentRuns(root.mcpServer).archiveLimit,
|
||||
},
|
||||
...validateWorkflow(root),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { commands, call, isTauri, onIdeEvent, type McpServerStatus } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Button, Input, T, copyText } from "@/ui";
|
||||
import { Row } from "../components/Form";
|
||||
@@ -135,22 +134,6 @@ export const MCP_SERVER_PAGE: PageSpec = {
|
||||
label: "Run tools without confirmation (brave mode)",
|
||||
hint: "Run configurations, terminal commands and other tools that change a project start without asking, for unattended agents. Only the IDE's own settings can turn this on, not a project's.",
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "mcpServer.agentTabLimit",
|
||||
label: "Finished agent tabs to show",
|
||||
...WORKFLOW_LIMITS.agentTabLimit,
|
||||
integer: true,
|
||||
hint: "Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.",
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "mcpServer.agentArchiveLimit",
|
||||
label: "Archived agent tabs to keep",
|
||||
...WORKFLOW_LIMITS.agentArchiveLimit,
|
||||
integer: true,
|
||||
hint: "Beyond this many archived agent tabs, the oldest are closed.",
|
||||
},
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Endpoint",
|
||||
|
||||
@@ -682,7 +682,7 @@ export const TOOLS_PAGES: PageSpec[] = [
|
||||
key: "agents.newTaskApprovals",
|
||||
label: "New tasks start",
|
||||
options: NEW_TASK_APPROVAL_OPTIONS,
|
||||
hint: "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
|
||||
hint: "Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ import { usePostfixSettings } from "./postfix/store";
|
||||
import { showChooser, type ChooserItem } from "./popup/store";
|
||||
import { expandEmmetAtCaret } from "./emmet/editor";
|
||||
import { hasEmmet, loadEmmet } from "./emmet/model";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface TemplateDocInfo {
|
||||
path: string;
|
||||
@@ -83,7 +82,7 @@ function userName(): string {
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
return await readClipboardPlainText();
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -142,27 +142,6 @@ describe("ownTerminalInput", () => {
|
||||
expect(reached).toEqual(["keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("sends a shifted character an IME commits before its keydown", () => {
|
||||
// WeChat / Pinyin on macOS, Shift+/ for `?`: insertText arrives while only Shift is down.
|
||||
// Its keydown armed xterm's gate, which then drops the first press.
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
input(textarea, { inputType: "insertText", data: "?" });
|
||||
keydown(textarea, { key: "?", keyCode: 229 });
|
||||
keyup(textarea, 191);
|
||||
expect(sent).toEqual(["?"]);
|
||||
expect(reached).toEqual(["keydown"]);
|
||||
});
|
||||
|
||||
it("leaves shifted keys xterm sent itself to xterm", () => {
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
keydown(textarea, { key: "A", keyCode: 65 });
|
||||
input(textarea, { inputType: "insertText", data: "A" });
|
||||
expect(sent).toEqual([]);
|
||||
expect(reached).toEqual(["keydown", "keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("leaves input with no keydown behind it to xterm", () => {
|
||||
// Dictation, an on-screen keyboard or the emoji picker.
|
||||
const { textarea, sent, reached } = setup();
|
||||
@@ -280,25 +259,6 @@ describe("ownTerminalInput with xterm", () => {
|
||||
return sent;
|
||||
}
|
||||
|
||||
it("sends `?` on the first and the second press of Shift+/ with a direct-commit IME", async () => {
|
||||
const sent = await replay([
|
||||
["keydown", "Shift", 16],
|
||||
["input", "insertText", "?", "?"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["input", "insertText", "?", "??"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["keyup", "Shift", 16],
|
||||
["keydown", "Shift", 16],
|
||||
["keydown", "A", 65],
|
||||
["input", "insertText", "A", "??A"],
|
||||
["keyup", "A", 65],
|
||||
["keyup", "Shift", 16],
|
||||
]);
|
||||
expect(sent).toEqual(["?", "?", "A"]);
|
||||
});
|
||||
|
||||
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
|
||||
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
|
||||
const sent = await replay([
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run;
|
||||
* - while such a keydown is pending, an `insertText` is sent from the event's own `data`
|
||||
* (not from a value diff), then the textarea is cleared;
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way;
|
||||
* - so is an `insertText` while only a modifier is down: an IME that commits a shifted
|
||||
* character directly (`?` from Shift+/ with WeChat or Pinyin) delivers it before the
|
||||
* character's keydown, and the Shift keydown already armed xterm's gate, so the first press
|
||||
* was dropped.
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way.
|
||||
*
|
||||
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
|
||||
* input events for keys xterm already sent, and input with no IME keydown behind it.
|
||||
@@ -32,8 +28,6 @@
|
||||
*/
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
|
||||
|
||||
/** Commit-shaped input events that xterm's own input path ignores entirely. */
|
||||
const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]);
|
||||
|
||||
@@ -68,9 +62,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
let imeKey = false;
|
||||
/** The pending key belongs to an IME: its commit is ours to deliver. */
|
||||
let imeDown = false;
|
||||
/** Since the last keyup: a modifier went down, and any other key went down. */
|
||||
let modifierDown = false;
|
||||
let keyDown = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (composing) {
|
||||
@@ -78,8 +69,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.keyCode === 0) e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (MODIFIERS.has(e.key)) modifierDown = true;
|
||||
else keyDown = true;
|
||||
imeKey = isImeKey(e);
|
||||
imeDown = imeKey;
|
||||
if (imeKey) {
|
||||
@@ -101,8 +90,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
const onKeyUp = () => {
|
||||
imeKey = false;
|
||||
imeDown = false;
|
||||
modifierDown = false;
|
||||
keyDown = false;
|
||||
};
|
||||
|
||||
const onInput = (e: InputEvent) => {
|
||||
@@ -110,8 +97,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.inputType === "insertText") {
|
||||
// Without an IME keydown behind it this is xterm's: it drops the event for a key it
|
||||
// already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji).
|
||||
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
|
||||
if (!imeDown && !(modifierDown && !keyDown)) return;
|
||||
if (!imeDown) return;
|
||||
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
|
||||
|
||||
const notify = vi.hoisted(() => vi.fn());
|
||||
@@ -526,28 +525,3 @@ it("recovers quoted local file paths when clipboard text permission is denied",
|
||||
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// WebKitGTK pastes nothing from a menu through execCommand or readText: native text first.
|
||||
{ os: "linux", native: "ls -la", expected: ["ls -la"], execCommand: 0 },
|
||||
// An image-only clipboard keeps the WebView paths.
|
||||
{ os: "linux", native: "", expected: [], execCommand: 1 },
|
||||
{ os: "macos", native: "ls -la", expected: ["ls -la"], execCommand: 1 },
|
||||
] as const)("menu paste on $os with native text '$native'", async ({ os, native, expected, execCommand }) => {
|
||||
usePlatform.setState({ os });
|
||||
vi.spyOn(commands, "clipboardText").mockResolvedValue({ status: "ok", data: native });
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "web" } });
|
||||
const exec = vi.fn(() => false);
|
||||
Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: () => true });
|
||||
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
|
||||
const paste = vi.fn();
|
||||
try {
|
||||
await pasteTerminalClipboard(paste, () => localTarget());
|
||||
} finally {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
Reflect.deleteProperty(document, "queryCommandSupported");
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
}
|
||||
expect(exec).toHaveBeenCalledTimes(execCommand);
|
||||
expect(paste.mock.calls).toEqual(expected.map((text) => [text]));
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pasteClipboardFiles, readClipboardPlainText, readClipboardText } from "@/platform/clipboard";
|
||||
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
|
||||
import { call, commands, type TerminalId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export interface TerminalPasteTarget {
|
||||
@@ -121,15 +120,6 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
|
||||
export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) {
|
||||
const target = getTarget?.();
|
||||
try {
|
||||
// WebKitGTK pastes nothing from a menu through either WebView path; the native
|
||||
// clipboard has the text. An image-only clipboard still takes the paths below.
|
||||
if (target && usePlatform.getState().os === "linux") {
|
||||
const text = await readClipboardPlainText().catch(() => "");
|
||||
if (text) {
|
||||
if (target.current()) paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Native paste preserves clipboard blobs where Clipboard.read() is unavailable.
|
||||
if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return;
|
||||
const images =
|
||||
@@ -140,7 +130,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
)
|
||||
: null;
|
||||
const text = target?.remote
|
||||
? await readClipboardPlainText().catch(() => "")
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Branch, ChangedFile, Commit } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { maybeEditorService, useGitLogChecks, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -2628,7 +2627,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
|
||||
|
||||
if (!commit)
|
||||
return (
|
||||
@@ -2669,13 +2667,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
const details = (
|
||||
<SplitGroup orientation="vertical" id="git-log-details">
|
||||
<SplitPanel id="files" defaultSize="60%" minSize="60px">
|
||||
<div
|
||||
className={styles.filesPane}
|
||||
onFocusCapture={() => setContext("vcsLogChangesFocus", true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("vcsLogChangesFocus", false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.filesPane}>
|
||||
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
|
||||
<IconButton
|
||||
icon="diff"
|
||||
|
||||
@@ -62,7 +62,6 @@ import { refreshNow } from "./store";
|
||||
import styles from "./MergeEditor.module.css";
|
||||
|
||||
import type { MergeData } from "./mergeKind";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
|
||||
|
||||
const NOTHING_LEFT: MergeSummary = {
|
||||
@@ -498,7 +497,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
else if (id === "paste") {
|
||||
const text = await readClipboardPlainText().catch(() => undefined);
|
||||
const text = await navigator.clipboard?.readText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileWebUrl, remoteHosting } from "../model";
|
||||
|
||||
describe("remoteHosting", () => {
|
||||
it.each([
|
||||
["[email protected]:o/r.git", "GitHub", "github"],
|
||||
["https://github.example.com/o/r", "GitHub", "github"],
|
||||
["https://gitee.com/o/r.git", "Gitee", "github"],
|
||||
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
|
||||
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
|
||||
["[email protected]:group/r.git", "git.example.com", "gitlab"],
|
||||
])("%s → %s", (url, name, kind) => {
|
||||
expect(remoteHosting(url)).toEqual({ name, kind });
|
||||
});
|
||||
|
||||
it("has none for a local remote", () => {
|
||||
expect(remoteHosting("/srv/git/r.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileWebUrl", () => {
|
||||
it("opens a file at a line on GitHub", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "src/a b.ts", { line: 9 })).toBe(
|
||||
"https://github.com/o/r/blob/main/src/a%20b.ts#L10",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a branch's slashes and opens directories as trees", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "feature/x", "src", { dir: true, line: 3 })).toBe(
|
||||
"https://github.com/o/r/tree/feature/x/src",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses GitLab's /-/ routes, including subgroups", () => {
|
||||
expect(fileWebUrl("ssh://[email protected]:2222/group/sub/r.git", "abc123", "docs/中文.md", { line: 0 })).toBe(
|
||||
"https://gitlab.example.com/group/sub/r/-/blob/abc123/docs/%E4%B8%AD%E6%96%87.md#L1",
|
||||
);
|
||||
expect(fileWebUrl("[email protected]:g/r.git", "main", "", { dir: true })).toBe("https://gitlab.com/g/r/-/tree/main");
|
||||
});
|
||||
|
||||
it("uses Bitbucket's /src/ routes", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "a.py", { line: 4 })).toBe(
|
||||
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,7 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { loadLogDetails, useCommitDetails } from "../logDetails";
|
||||
import { loadLogDetails } from "../logDetails";
|
||||
import { useVcs } from "../store";
|
||||
import { useGitLog } from "../logStore";
|
||||
const commit = (id: string): Commit => ({
|
||||
@@ -93,38 +92,3 @@ describe("commit detail jobs", () => {
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCommitDetails", () => {
|
||||
it("keeps the shown details while a repository refresh reloads them", async () => {
|
||||
const c = commit("kept-on-refresh");
|
||||
const { result, rerender } = renderHook(({ c }) => useCommitDetails(c), { initialProps: { c } });
|
||||
await vi.waitFor(() => expect(result.current.details?.message).toBe("kept-on-refresh"));
|
||||
let finish!: () => void;
|
||||
api.batch.mockImplementationOnce(
|
||||
(_repo: string, ids: string[]) =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
status: "ok",
|
||||
data: ids.map(() => ({
|
||||
message: "reloaded",
|
||||
containingBranches: ["main"],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
act(() => useVcs.setState((s) => ({ version: s.version + 1 })));
|
||||
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.details?.message).toBe("kept-on-refresh");
|
||||
await act(async () => finish());
|
||||
await vi.waitFor(() => expect(result.current.details?.message).toBe("reloaded"));
|
||||
// Another commit shows nothing until its own details arrive.
|
||||
api.batch.mockImplementationOnce(() => new Promise(() => {}));
|
||||
rerender({ c: commit("another") });
|
||||
expect(result.current.details).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -373,8 +373,7 @@ export function registerVcsActions(): () => void {
|
||||
icon: "diff",
|
||||
when: VCS,
|
||||
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
|
||||
// In the Git Log's changed files it shows the commit's diff instead.
|
||||
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
|
||||
keyWhen: "!editorTextFocus",
|
||||
run: async ({ arg, ctx }) => {
|
||||
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
|
||||
const line =
|
||||
|
||||
@@ -14,7 +14,6 @@ export { compareFilesData, displayPath } from "./compareData";
|
||||
import { openDiff, openVcsDiff, type DiffData } from "./diffInput";
|
||||
import { baseName } from "./model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function argPath(arg: unknown): string | undefined {
|
||||
if (typeof arg === "string") return arg;
|
||||
@@ -286,7 +285,7 @@ export function registerCompareActions(): () => void {
|
||||
if (!path) return info("Open or select a file to compare with the clipboard");
|
||||
let clipboard: string;
|
||||
try {
|
||||
clipboard = await readClipboardPlainText();
|
||||
clipboard = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return info("The clipboard could not be read");
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
|
||||
<div
|
||||
ref={body}
|
||||
className="vcsDiffDialogBody"
|
||||
// The dialog body is not a flex container: fill it so the editors reach the footer.
|
||||
style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
registerTitleItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerVcsActions } from "./actions";
|
||||
@@ -50,7 +50,6 @@ import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore"
|
||||
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
|
||||
import { installRootSync } from "./rootSync";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openOnRemoteActions, registerOpenOnRemote } from "./openOnRemote";
|
||||
|
||||
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
|
||||
function trackMergeRebaseItem(): () => void {
|
||||
@@ -232,8 +231,6 @@ export function activate(): () => void {
|
||||
(signOff) => useCommit.setState({ signOff }),
|
||||
),
|
||||
registerVcsActions(),
|
||||
registerActions(openOnRemoteActions),
|
||||
registerOpenOnRemote(),
|
||||
registerHistoryActions(),
|
||||
registerLogPaneActions(),
|
||||
activateSettingsPages(),
|
||||
|
||||
@@ -126,28 +126,20 @@ export function useCommitDetails(commit: Commit, enabled = true) {
|
||||
const version = useVcs((s) => s.version);
|
||||
const repo = commitRepo(commit) ?? currentRepo();
|
||||
const refs = refVersions((s) => s[repo ?? ""] ?? 0);
|
||||
const target = `${repo}\0${commit.id}`;
|
||||
const key = `${target}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{
|
||||
target: string;
|
||||
details: LogDetails | null;
|
||||
error: boolean;
|
||||
} | null>(null);
|
||||
const key = `${repo}\0${commit.id}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void loadLogDetails(commit, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) setResult({ target, details, error: false });
|
||||
if (!controller.signal.aborted) setResult({ key, details, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setResult({ target, details: null, error: true });
|
||||
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, enabled]);
|
||||
// A repository refresh reloads the same commit's details: keep showing the last ones until
|
||||
// the new ones arrive, or the section blinks on every refresh.
|
||||
const current = result?.target === target ? result : null;
|
||||
return { details: current?.details ?? null, error: !!current?.error };
|
||||
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
|
||||
}
|
||||
@@ -152,7 +152,6 @@ export function registerLogPaneActions(): () => void {
|
||||
icon: "diff",
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
keyWhen: "vcsLogChangesFocus",
|
||||
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -165,51 +165,7 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
|
||||
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
|
||||
}
|
||||
|
||||
/** How a Git hosting service lays out its file pages. */
|
||||
export type HostingKind = "github" | "gitlab" | "bitbucket";
|
||||
|
||||
/**
|
||||
* The hosting service of a remote, by its host: GitHub (and GitHub Enterprise, Gitee, which share
|
||||
* its URLs), Bitbucket, and GitLab for gitlab.* and any other host, as self-managed GitLab is the
|
||||
* usual company server. Null for remotes with no web page (local paths).
|
||||
*/
|
||||
export function remoteHosting(url: string): { name: string; kind: HostingKind } | null {
|
||||
const base = remoteWebUrl(url);
|
||||
if (!base) return null;
|
||||
const host = new URL(base).hostname.toLowerCase();
|
||||
if (host === "github.com" || host.includes("github")) return { name: "GitHub", kind: "github" };
|
||||
if (host === "gitee.com") return { name: "Gitee", kind: "github" };
|
||||
if (host === "bitbucket.org") return { name: "Bitbucket", kind: "bitbucket" };
|
||||
if (host.includes("gitlab")) return { name: "GitLab", kind: "gitlab" };
|
||||
return { name: host, kind: "gitlab" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page of a file or directory at `revision` on the remote's web UI, with the 0-based `line`
|
||||
* selected. `relative` is the `/`-separated path inside the repository ("" for its root).
|
||||
*/
|
||||
export function fileWebUrl(
|
||||
remoteUrl: string,
|
||||
revision: string,
|
||||
relative: string,
|
||||
opts: { dir?: boolean; line?: number } = {},
|
||||
): string | null {
|
||||
const base = remoteWebUrl(remoteUrl);
|
||||
const hosting = remoteHosting(remoteUrl);
|
||||
if (!base || !hosting) return null;
|
||||
const path = [...revision.split("/"), ...relative.split("/")].filter(Boolean).map(encodeURIComponent).join("/");
|
||||
const line = opts.line !== undefined && !opts.dir ? opts.line + 1 : null;
|
||||
switch (hosting.kind) {
|
||||
case "github":
|
||||
return `${base}/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "gitlab":
|
||||
return `${base}/-/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "bitbucket":
|
||||
return `${base}/src/${path}${line ? `#lines-${line}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */ /** "N file(s)", worded for the locale. */
|
||||
/** "N file(s)", worded for the locale. */
|
||||
export function fileCount(n: number): string {
|
||||
return n === 1 ? tx`${n} file` : tx`${n} files`;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Open In › GitHub / GitLab / Bitbucket: the file (at the caret line) or directory on the web UI
|
||||
* of its repository's remote, at the current branch's upstream (else the HEAD commit).
|
||||
*/
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { notifications, registerMenuGroup } from "@/workbench/contributions";
|
||||
import { openUrl } from "./issueLinks";
|
||||
import { fileWebUrl, remoteHosting, rootOf } from "./model";
|
||||
import { useVcs } from "./store";
|
||||
|
||||
interface Target {
|
||||
path: string;
|
||||
dir?: boolean;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** Root → its remote's URL (null: none), read when the repositories change. */
|
||||
const remoteUrls = new Map<string, string | null>();
|
||||
|
||||
/** The remote a root's files open on: the selected repository's upstream remote, else `origin`. */
|
||||
function remoteName(root: string): string {
|
||||
const { repo, branch } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
return upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
|
||||
}
|
||||
|
||||
async function readRemoteUrl(root: string): Promise<string | null> {
|
||||
const url = await call(commands.vcsConfigGet(root, `remote.${remoteName(root)}.url`)).catch(() => null);
|
||||
remoteUrls.set(root, url || null);
|
||||
return url || null;
|
||||
}
|
||||
|
||||
function revision(root: string): string | null {
|
||||
const { repo, branch, heads } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
if (upstream) return upstream.slice(upstream.indexOf("/") + 1);
|
||||
return heads[root]?.headCommit ?? null;
|
||||
}
|
||||
|
||||
function targetArg(arg: unknown): Target | null {
|
||||
if (typeof arg === "string") return { path: arg };
|
||||
if (arg && typeof arg === "object" && typeof (arg as Target).path === "string") return arg as Target;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openOnRemote(target: Target) {
|
||||
const root = rootOf(target.path, useVcs.getState().roots);
|
||||
const url = root ? (remoteUrls.get(root) ?? (await readRemoteUrl(root))) : null;
|
||||
const rev = root ? revision(root) : null;
|
||||
const page =
|
||||
root && url && rev ? fileWebUrl(url, rev, target.path.slice(root.length).replace(/^\/+/, ""), target) : null;
|
||||
if (page) await openUrl(page);
|
||||
else notifications().notify({ group: "vcs", level: "warning", title: "This file has no page on a Git remote" });
|
||||
}
|
||||
|
||||
export const openOnRemoteActions: Action[] = [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
title: "Open on Git Remote",
|
||||
description: "Opens the file or directory on the web page of its repository's remote.",
|
||||
category: "Git",
|
||||
icon: "external-link",
|
||||
when: "vcsAvailable",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = targetArg(arg);
|
||||
if (target) void openOnRemote(target);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Open In › <hosting>, for files and directories inside a repository with a web remote. */
|
||||
export function registerOpenOnRemote(): () => void {
|
||||
const refresh = (roots: readonly string[]) => {
|
||||
for (const root of [...remoteUrls.keys()]) if (!roots.includes(root)) remoteUrls.delete(root);
|
||||
for (const root of roots) void readRemoteUrl(root);
|
||||
};
|
||||
refresh(useVcs.getState().roots);
|
||||
const unsubscribe = useVcs.subscribe((s, prev) => {
|
||||
if (s.roots !== prev.roots || s.branch?.upstream !== prev.branch?.upstream) refresh(s.roots);
|
||||
});
|
||||
const unregister = registerMenuGroup({
|
||||
id: "vcs.openOnRemote",
|
||||
menus: ["openIn"],
|
||||
items: (_menu, { paths, dirs, line }) => {
|
||||
if (paths.length !== 1) return [];
|
||||
const path = paths[0]!;
|
||||
const root = rootOf(path, useVcs.getState().roots);
|
||||
const url = root ? remoteUrls.get(root) : null;
|
||||
const hosting = url ? remoteHosting(url) : null;
|
||||
if (!hosting) return [];
|
||||
const target: Target = { path, dir: dirs?.[0] ?? false, line };
|
||||
return [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
// A service or host name, the same in every language.
|
||||
label: hosting.name,
|
||||
verbatim: true,
|
||||
icon: hosting.kind === "github" ? "github" : "external-link",
|
||||
run: () => void openOnRemote(target),
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unregister();
|
||||
remoteUrls.clear();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
/** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -224,7 +224,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
|
||||
let source = "Clipboard";
|
||||
try {
|
||||
if (from === "clipboard") {
|
||||
text = await readClipboardPlainText();
|
||||
text = await navigator.clipboard.readText();
|
||||
} else {
|
||||
let file = path ?? null;
|
||||
if (!file) {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** The app's CSP, which Tauri sends as a response header with every HTML page it serves. */
|
||||
function directives(): Map<string, string[]> {
|
||||
const conf = JSON.parse(readFileSync("src-tauri/tauri.conf.json", "utf8"));
|
||||
const csp: string = conf.app.security.csp;
|
||||
const map = new Map<string, string[]>();
|
||||
for (const part of csp.split(";")) {
|
||||
const [name, ...sources] = part.trim().split(/\s+/);
|
||||
if (name) map.set(name.toLowerCase(), sources);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
describe("app content security policy", () => {
|
||||
it("does not let any page embed the app's own pages", () => {
|
||||
expect(directives().get("frame-ancestors")).toEqual(["'none'"]);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user