mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48becbfff | ||
|
|
14ee1a97f2 | ||
|
|
916c4243bf | ||
|
|
9c63f174e4 | ||
|
|
b463252bc5 | ||
|
|
2abdac4fdb | ||
|
|
721f4144c1 | ||
|
|
55057b8958 | ||
|
|
8abc8e7fbf | ||
|
|
68dd949df3 | ||
|
|
b98bddbbab | ||
|
|
21b05ab73a | ||
|
|
7d7de0b966 | ||
|
|
830f4aa802 |
No files matched your search
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -373,6 +380,14 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -8,13 +8,18 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
/// The `encoding_rs` encoding of a Windows code page, when it has one.
|
||||
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
|
||||
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
|
||||
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
|
||||
pub fn console_code_page() -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
|
||||
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
|
||||
(cp != 65001).then_some(cp)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
|
||||
/// piece by piece.
|
||||
#[cfg(windows)]
|
||||
pub fn is_single_byte_code_page(cp: u32) -> bool {
|
||||
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
|
||||
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
|
||||
unsafe {
|
||||
let mut info: CPINFO = std::mem::zeroed();
|
||||
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
|
||||
/// like the OEM pages 437, 850 or 852).
|
||||
#[cfg(windows)]
|
||||
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
|
||||
use windows_sys::Win32::Globalization::MultiByteToWideChar;
|
||||
let Ok(len) = i32::try_from(bytes.len()) else {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
};
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
|
||||
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
|
||||
if needed <= 0 {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut wide = vec![0u16; needed as usize];
|
||||
// SAFETY: `wide` holds `needed` units.
|
||||
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
|
||||
String::from_utf16_lossy(&wide[..written.max(0) as usize])
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
@@ -296,6 +296,16 @@ pub struct McpServerSettings {
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
}
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Host {
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
@@ -141,6 +143,7 @@ impl Host {
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
@@ -269,6 +272,7 @@ impl Host {
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.mcp_agent_runs.forget_window(window);
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
mod run;
|
||||
pub(crate) mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
@@ -16,6 +16,7 @@ use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,418 @@
|
||||
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
|
||||
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
const RUN_TIMEOUT_MS: usize = 10_000;
|
||||
const WAIT_TIMEOUT_MS: usize = 30_000;
|
||||
const STOP_TIMEOUT_MS: usize = 10_000;
|
||||
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
|
||||
const DEFAULT_MAX_LINES: usize = 2000;
|
||||
/// Bytes of the log read for one result at most.
|
||||
const READ_CAP: usize = 1 << 20;
|
||||
/// Lines of output quoted when a name is still running.
|
||||
const BUSY_TAIL_LINES: usize = 20;
|
||||
|
||||
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
|
||||
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
|
||||
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
|
||||
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
|
||||
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
|
||||
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
|
||||
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
|
||||
wait for their confirmation before you continue.";
|
||||
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
|
||||
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
|
||||
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
|
||||
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
|
||||
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
|
||||
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
|
||||
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
|
||||
(keeps the start), MIDDLE (keeps both ends) or NONE";
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
call.host()
|
||||
.mcp_agent_runs
|
||||
.get(call.root(), &name)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
|
||||
}
|
||||
|
||||
/// `status` of `run`, with interventions not told yet.
|
||||
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
|
||||
let mut out = run.status();
|
||||
out.extend(extra);
|
||||
let interventions = run.take_interventions();
|
||||
if !interventions.is_empty() {
|
||||
out.insert("interventions".into(), interventions.into());
|
||||
out.insert("advice".into(), INTERVENTION_ADVICE.into());
|
||||
}
|
||||
Output::json(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
|
||||
/// as `truncateMode` says.
|
||||
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
|
||||
let max_lines = match call.opt_i64("maxLinesCount")? {
|
||||
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
|
||||
Some(n) => n as usize,
|
||||
None => DEFAULT_MAX_LINES,
|
||||
};
|
||||
let mode = match call.opt_str("truncateMode")? {
|
||||
Some(name) => TruncateMode::parse(&name)
|
||||
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
|
||||
None => TruncateMode::Start,
|
||||
};
|
||||
let len = run.log_len();
|
||||
let from = from.min(len);
|
||||
// A long output is read from the end that stays.
|
||||
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
|
||||
let (raw, start, end) = run.read(start, READ_CAP)?;
|
||||
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
|
||||
let mut out = Map::new();
|
||||
out.insert("output".into(), text.into());
|
||||
if truncated > 0 {
|
||||
out.insert("truncatedLines".into(), truncated.into());
|
||||
}
|
||||
out.insert("outputStart".into(), start.into());
|
||||
out.insert("nextOffset".into(), end.into());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
|
||||
Ok(tokio::time::Instant::now() + timeout(call, default)?)
|
||||
}
|
||||
|
||||
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
|
||||
call.opt_str("pattern")?
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
|
||||
match call.opt_i64(name)? {
|
||||
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
|
||||
n => Ok(n.map(|n| n as u64)),
|
||||
}
|
||||
}
|
||||
|
||||
fn busy(run: &AgentRun) -> IdeError {
|
||||
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
|
||||
IdeError::invalid(format!(
|
||||
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
|
||||
run.name,
|
||||
run.command(),
|
||||
clean_output(&tail).trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_command() -> Tool {
|
||||
Tool::new(
|
||||
"run_command",
|
||||
&format!(
|
||||
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
|
||||
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
|
||||
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
|
||||
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
|
||||
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
|
||||
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
|
||||
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
let command = call.str("command")?;
|
||||
if command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("command must not be empty"));
|
||||
}
|
||||
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
|
||||
Some(dir) => {
|
||||
let path = call.resolve_inside(&dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
|
||||
}
|
||||
path
|
||||
}
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
let runs = &host.mcp_agent_runs;
|
||||
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
|
||||
return Err(busy(&run));
|
||||
}
|
||||
let shown_cwd = call.display(&cwd);
|
||||
let mut detail = format!(
|
||||
"{command}\n\nTab: {name}\nWorking directory: {}",
|
||||
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
|
||||
);
|
||||
for (key, value) in env.iter().flatten() {
|
||||
detail.push_str(&format!("\n{key}={value}"));
|
||||
}
|
||||
call.confirm(&name, Some(detail)).await?;
|
||||
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
|
||||
let request = ide_core::event::McpUiRequest::AgentRun {
|
||||
name: name.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.into(),
|
||||
env: env.unwrap_or_default().into_iter().collect(),
|
||||
};
|
||||
if let Err(e) = call.ask(request).await {
|
||||
let error = run.start_error();
|
||||
runs.abandon(&run);
|
||||
return Err(match error {
|
||||
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
if let Some(error) = run.start_error() {
|
||||
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
|
||||
}
|
||||
let from = run.process_offset();
|
||||
let end = run.wait(deadline, None, from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::required(
|
||||
"command",
|
||||
ParamType::String,
|
||||
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Directory inside the project to run in, relative to the project root; default: the project root",
|
||||
))
|
||||
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
|
||||
.param(Param::optional(
|
||||
"timeout",
|
||||
ParamType::Integer,
|
||||
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
|
||||
keeps running after it",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn wait_for_run() -> Tool {
|
||||
Tool::new(
|
||||
"wait_for_run",
|
||||
&format!(
|
||||
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
|
||||
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
|
||||
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
|
||||
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
|
||||
let pattern = pattern(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
|
||||
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
|
||||
let mut extra = output_view(&call, &run, from)?;
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
|
||||
.param(Param::optional(
|
||||
"pattern",
|
||||
ParamType::String,
|
||||
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"offset",
|
||||
ParamType::Integer,
|
||||
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
|
||||
command's output starts",
|
||||
))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn read_run_output() -> Tool {
|
||||
Tool::new(
|
||||
"read_run_output",
|
||||
&format!(
|
||||
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
|
||||
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
|
||||
{OUTPUT}{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let from = offset(&call, "offset")?.unwrap_or(0);
|
||||
let extra = output_view(&call, &run, from)?;
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
|
||||
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
|
||||
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
|
||||
}
|
||||
|
||||
pub fn get_run_status() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_status",
|
||||
&format!(
|
||||
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
|
||||
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
|
||||
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
|
||||
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
}
|
||||
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let runs: Vec<Value> =
|
||||
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
|
||||
Output::json(serde_json::json!({ "runs": runs }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Key {
|
||||
/// Writes this text to its input.
|
||||
Text(&'static str),
|
||||
/// Ctrl+C: stops it, as `stop_run` does.
|
||||
Interrupt,
|
||||
/// Ctrl+D: ends its input.
|
||||
EndOfInput,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> IdeResult<Key> {
|
||||
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
|
||||
"enter" | "return" => Key::Text("\n"),
|
||||
"tab" => Key::Text("\t"),
|
||||
"ctrl+c" => Key::Interrupt,
|
||||
"ctrl+d" => Key::EndOfInput,
|
||||
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
|
||||
Ctrl+C to stop it or Ctrl+D to end its input"
|
||||
)));
|
||||
}
|
||||
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_run_input() -> Tool {
|
||||
Tool::new(
|
||||
"send_run_input",
|
||||
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
|
||||
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
|
||||
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
|
||||
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let text = call.opt_str("text")?.unwrap_or_default();
|
||||
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
|
||||
if text.is_empty() && keys.is_empty() {
|
||||
return Err(IdeError::invalid("pass `text`, `keys` or both"));
|
||||
}
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
call.project().require_trust()?;
|
||||
let host = call.host();
|
||||
let mut data = text;
|
||||
for key in keys {
|
||||
match key {
|
||||
Key::Text(text) => data.push_str(text),
|
||||
Key::EndOfInput | Key::Interrupt => {
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
|
||||
}
|
||||
if key == Key::EndOfInput {
|
||||
crate::ops::run::close_stdin(&host, id)?;
|
||||
} else {
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
host.processes.write_stdin(id, &data)?;
|
||||
}
|
||||
result(&run, Map::new())
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("text", ParamType::String, "Text to type"))
|
||||
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
|
||||
}
|
||||
|
||||
pub fn stop_run() -> Tool {
|
||||
Tool::new(
|
||||
"stop_run",
|
||||
&format!(
|
||||
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
|
||||
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
|
||||
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
|
||||
keeps `stopping`: call again with `force`. The tab stays open with its output."
|
||||
),
|
||||
|call| async move {
|
||||
let run = find(&call)?;
|
||||
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
|
||||
let force = call.flag("force", false)?;
|
||||
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
|
||||
let host = call.host();
|
||||
run.agent_stopping();
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
if force {
|
||||
// A second Stop kills the tree.
|
||||
crate::ops::run::stop(&host, &run.window(), id)?;
|
||||
}
|
||||
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
|
||||
let mut extra = Map::new();
|
||||
extra.insert("waitEnded".into(), end.as_str().into());
|
||||
result(&run, extra)
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("name", ParamType::String, NAME))
|
||||
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
|
||||
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
|
||||
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
|
||||
assert!(key("F13").unwrap_err().message.contains("unknown key"));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 60_000;
|
||||
const MAX_TIMEOUT_MS: usize = 3_600_000;
|
||||
const MAX_PROBLEMS: usize = 300;
|
||||
/// Console lines attached to a failure whose messages were not parsed.
|
||||
const CONSOLE_LINES: usize = 200;
|
||||
@@ -84,13 +83,13 @@ You have to use this tool after performing edits to validate if the edits are va
|
||||
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
|
||||
modules containing those files. Asks the user first for every call. Problems have the project-relative \
|
||||
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
|
||||
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|
||||
is an ERROR. On timeout (0 times out at once) the build is stopped.",
|
||||
|call| async move {
|
||||
let files: Vec<String> =
|
||||
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
|
||||
let rebuild = call.flag("rebuild", false)? && files.is_empty();
|
||||
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
|
||||
let mut targets: Vec<Option<PathBuf>> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for f in &files {
|
||||
|
||||
@@ -19,8 +19,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// No limit in JetBrains; the process keeps running after the timeout anyway.
|
||||
const MAX_TIMEOUT_MS: usize = 1_800_000;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
@@ -541,7 +539,7 @@ tool window; output files are private and deleted after a day.",
|
||||
)?;
|
||||
let wait = call.flag("waitForExit", true)?;
|
||||
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
|
||||
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let configs = load_configs(&call).await?;
|
||||
let mut config = match &target {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
//! Run configurations, builds and agent commands; every start asks the user first.
|
||||
|
||||
pub(crate) mod agent;
|
||||
mod agent_tools;
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
@@ -11,15 +13,22 @@ use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
let mut tools =
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
|
||||
tools.extend(agent_tools::tools());
|
||||
tools
|
||||
}
|
||||
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
|
||||
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
|
||||
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
|
||||
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
|
||||
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
|
||||
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
|
||||
fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
|
||||
Ok(match call.opt_i64("timeout")? {
|
||||
None => Duration::from_millis(default as u64),
|
||||
Some(n) if n <= 0 => Duration::ZERO,
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
|
||||
})
|
||||
}
|
||||
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
|
||||
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
|
||||
/// runs from the workspace root), else its working directory.
|
||||
fn scope_of(root: &Path, c: &RunConfig) -> String {
|
||||
match c.extras.module.as_deref() {
|
||||
Some(module) if c.kind == "cargo" => {
|
||||
let module = module.replace('\\', "/");
|
||||
let module = module.strip_prefix("./").unwrap_or(&module);
|
||||
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
_ => cwd_of(root, c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
|
||||
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
|
||||
let score = |c: &RunConfig| -> Option<usize> {
|
||||
let cwd = cwd_of(root, c);
|
||||
let cwd = scope_of(root, c);
|
||||
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
|
||||
};
|
||||
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
|
||||
@@ -922,6 +935,33 @@ mod tests {
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_member_runs_its_own_tests() {
|
||||
let root = Path::new("/p");
|
||||
let member = |pkg: &str, dir: &str| {
|
||||
let mut c = blank(
|
||||
format!("cargo:test:{pkg}"),
|
||||
"test",
|
||||
"cargo",
|
||||
"cargo".into(),
|
||||
vec!["test".into(), "-p".into(), pkg.into()],
|
||||
);
|
||||
c.is_test = true;
|
||||
c.extras.module = Some(dir.into());
|
||||
c
|
||||
};
|
||||
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
|
||||
workspace.is_test = true;
|
||||
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
|
||||
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
|
||||
let rel = "crates/ide-mcp/src/stdio.rs";
|
||||
let ts = detect(src, rel);
|
||||
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
|
||||
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_args_get_one_separator() {
|
||||
let root = Path::new("/p");
|
||||
|
||||
@@ -234,7 +234,19 @@ pub async fn start_request(
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
let mut config = config;
|
||||
let agent = match host.project(window) {
|
||||
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
|
||||
Err(_) => None,
|
||||
};
|
||||
let on_output = match &agent {
|
||||
Some(agent) => agent.wrap_output(on_output),
|
||||
None => on_output,
|
||||
};
|
||||
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
|
||||
if let Some(agent) = &agent {
|
||||
agent.started(&result);
|
||||
}
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
@@ -249,6 +261,7 @@ async fn start_inner(
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
agent: Option<&crate::mcp::run::agent::Launch>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -282,6 +295,9 @@ async fn start_inner(
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if let Some(agent) = agent {
|
||||
sink = agent.wrap_sink(sink);
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -450,6 +466,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
host.mcp_agent_runs.stopping(id);
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
@@ -461,7 +478,9 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
/// Input the user typed into a run (the agent's goes to the process directly).
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
host.mcp_agent_runs.user_input(id, data);
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
|
||||
@@ -453,3 +453,147 @@ 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");
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
+249
-22
@@ -3,17 +3,39 @@
|
||||
use crate::discovery::Discovery;
|
||||
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use http_body_util::{BodyExt, Either, Full, Limited};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::{JoinError, JoinHandle};
|
||||
use tokio_util::sync::DropGuard;
|
||||
|
||||
/// Largest request body.
|
||||
const MAX_BODY: usize = 8 << 20;
|
||||
/// A call still running after this long is answered on an event stream (when the client
|
||||
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
|
||||
/// response has not started (Claude Code after a minute).
|
||||
#[cfg(not(test))]
|
||||
const STREAM_AFTER: Duration = Duration::from_secs(1);
|
||||
#[cfg(test)]
|
||||
const STREAM_AFTER: Duration = Duration::from_millis(100);
|
||||
/// How often a streamed call reports that it is still running: progress notifications reset
|
||||
/// the client's idle timeout (Claude Code's is five minutes).
|
||||
#[cfg(not(test))]
|
||||
const HEARTBEAT: Duration = Duration::from_secs(15);
|
||||
#[cfg(test)]
|
||||
const HEARTBEAT: Duration = Duration::from_millis(100);
|
||||
|
||||
type Handled = Result<Option<Value>, Value>;
|
||||
type Body = Either<Full<Bytes>, EventStream>;
|
||||
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
@@ -140,15 +162,11 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
req: Request<Incoming>,
|
||||
shared: Arc<Shared>,
|
||||
scope: String,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
|
||||
Ok(respond(req, &shared, scope).await)
|
||||
}
|
||||
|
||||
type Reply = Response<Full<Bytes>>;
|
||||
type Reply = Response<Body>;
|
||||
|
||||
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
|
||||
if req.uri().path() != "/mcp" {
|
||||
@@ -163,7 +181,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
return response;
|
||||
}
|
||||
if req.method() != Method::POST {
|
||||
// No server-sent event streams: every answer is in the POST's response.
|
||||
// No stream of server-initiated messages: every answer is in the POST's response.
|
||||
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
|
||||
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||
return response;
|
||||
@@ -175,25 +193,41 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
|
||||
let project = project_of(&req);
|
||||
let streams =
|
||||
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
|
||||
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
|
||||
};
|
||||
let initializing = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let message = serde_json::from_slice::<Value>(&body).ok();
|
||||
let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
|
||||
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
|
||||
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
|
||||
let response_scope = scope.clone();
|
||||
let registry = shared.registry.clone();
|
||||
let shared = shared.clone();
|
||||
let cancel = shared.cancel.child_token();
|
||||
let _guard = cancel.clone().drop_guard();
|
||||
let handled = tokio::task::spawn_blocking(move || {
|
||||
// Cancels the call when the client goes away (the response is dropped).
|
||||
let guard = cancel.clone().drop_guard();
|
||||
let mut handled = tokio::task::spawn_blocking(move || {
|
||||
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
|
||||
})
|
||||
.await;
|
||||
});
|
||||
if streams && !initializing {
|
||||
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
|
||||
Ok(done) => return answer(done, false, &response_scope),
|
||||
Err(_) => return event_stream(handled, id, progress_token, guard),
|
||||
}
|
||||
}
|
||||
let done = handled.await;
|
||||
drop(guard);
|
||||
answer(done, initializing, &response_scope)
|
||||
}
|
||||
|
||||
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
|
||||
match handled {
|
||||
Ok(Ok(Some(reply))) => {
|
||||
let mut response = json(StatusCode::OK, &reply);
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) {
|
||||
if initializing && let Ok(value) = HeaderValue::from_str(scope) {
|
||||
response.headers_mut().insert("mcp-session-id", value);
|
||||
}
|
||||
response
|
||||
@@ -204,6 +238,71 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a call that is still running on an event stream: a progress notification every
|
||||
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
|
||||
/// answer. The call is cancelled as soon as the client goes away.
|
||||
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut handled => {
|
||||
let reply = match done {
|
||||
Ok(Ok(Some(reply)) | Err(reply)) => reply,
|
||||
Ok(Ok(None)) => return,
|
||||
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
|
||||
};
|
||||
let _ = tx.send(event(&reply)).await;
|
||||
return;
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
let elapsed = started.elapsed();
|
||||
let still_running = match &token {
|
||||
Some(token) => event(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/progress",
|
||||
"params": {
|
||||
"progressToken": token,
|
||||
"progress": elapsed.as_secs_f64(),
|
||||
"message": format!("running for {}s", elapsed.as_secs()),
|
||||
},
|
||||
})),
|
||||
None => Bytes::from_static(b": running\n\n"),
|
||||
};
|
||||
if tx.send(still_running).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut response = Response::new(Either::Right(EventStream(rx)));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
/// One server-sent event carrying a JSON-RPC message.
|
||||
fn event(message: &Value) -> Bytes {
|
||||
Bytes::from(format!("event: message\ndata: {message}\n\n"))
|
||||
}
|
||||
|
||||
/// A `text/event-stream` body, written by [`event_stream`]'s task.
|
||||
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
|
||||
|
||||
impl hyper::body::Body for EventStream {
|
||||
type Data = Bytes;
|
||||
type Error = Infallible;
|
||||
|
||||
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
|
||||
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
@@ -242,22 +341,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(text.to_owned())));
|
||||
fn plain(status: StatusCode, text: &str) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::from(value.to_string())));
|
||||
fn json(status: StatusCode, value: &Value) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
fn empty(status: StatusCode) -> Reply {
|
||||
let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
}
|
||||
@@ -265,6 +364,134 @@ fn empty(status: StatusCode) -> Response<Full<Bytes>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
|
||||
struct Slow(Arc<AtomicBool>);
|
||||
|
||||
impl ToolRegistry for Slow {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/project")]
|
||||
}
|
||||
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
_: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, crate::ToolCallError> {
|
||||
if name == "wait" {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
return Err(crate::ToolCallError::Failed("cancelled".into()));
|
||||
}
|
||||
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
Ok("done".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> (McpServer, Arc<AtomicBool>) {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let registry = Arc::new(Slow(cancelled.clone()));
|
||||
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
|
||||
}
|
||||
|
||||
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
|
||||
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
|
||||
if let Some(token) = token {
|
||||
params["_meta"] = json!({ "progressToken": token });
|
||||
}
|
||||
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
|
||||
}
|
||||
|
||||
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
|
||||
let body = body.to_string();
|
||||
let port = server.port();
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let request = format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
server.token(),
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await.unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
/// The whole response, lowercased headers and the body as sent (chunked or not).
|
||||
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
|
||||
let (head, body) = out.split_once("\r\n\r\n").unwrap();
|
||||
(head.to_ascii_lowercase(), body.to_owned())
|
||||
}
|
||||
|
||||
fn events(body: &str) -> Vec<Value> {
|
||||
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
|
||||
}
|
||||
|
||||
const BOTH: &str = "application/json, text/event-stream";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
|
||||
let (server, _) = start();
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
|
||||
|
||||
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
|
||||
assert!(head.contains("content-type: text/event-stream"), "{head}");
|
||||
let messages = events(&body);
|
||||
let (answer, progress) = messages.split_last().unwrap();
|
||||
assert_eq!(answer["id"], 3);
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "done");
|
||||
assert!(progress.len() >= 2, "{body}");
|
||||
for pair in progress.windows(2) {
|
||||
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
|
||||
}
|
||||
for p in progress {
|
||||
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
|
||||
}
|
||||
|
||||
// Without a progress token, comments keep the stream going.
|
||||
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
|
||||
assert!(body.contains(": running"), "{body}");
|
||||
assert_eq!(events(&body).len(), 1, "{body}");
|
||||
|
||||
// A client that takes only JSON waits for it.
|
||||
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
|
||||
assert!(head.contains("content-type: application/json"), "{head}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
|
||||
let (server, cancelled) = start();
|
||||
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
|
||||
let mut head = Vec::new();
|
||||
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = stream.read(&mut buf).await.unwrap();
|
||||
assert!(n > 0, "the stream ended early");
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
|
||||
drop(stream);
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !cancelled.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the call was cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
|
||||
+106
-14
@@ -35,7 +35,8 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
|
||||
}
|
||||
|
||||
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
|
||||
/// writes its answer, if any, as one line to `output`.
|
||||
/// writes what it answers, if anything, one message per line to `output`: the
|
||||
/// answer, and before it the progress notifications of a long call.
|
||||
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
|
||||
where
|
||||
R: AsyncBufRead + Unpin,
|
||||
@@ -44,23 +45,37 @@ where
|
||||
let scope = crate::server::new_token()?;
|
||||
let mut lines = input.lines();
|
||||
let mut pending = tokio::task::JoinSet::new();
|
||||
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
||||
let mut eof = false;
|
||||
loop {
|
||||
if eof && pending.is_empty() && received.is_empty() {
|
||||
break;
|
||||
}
|
||||
let reply = tokio::select! {
|
||||
line = lines.next_line(), if !eof => {
|
||||
let Some(line) = line? else { eof = true; continue };
|
||||
if line.trim().is_empty() { continue; }
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => {
|
||||
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone());
|
||||
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await });
|
||||
let (discovery, project, scope, sent) =
|
||||
(discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
|
||||
pending.spawn(async move {
|
||||
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
|
||||
let _ = sent.send(reply);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
|
||||
}
|
||||
}
|
||||
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None },
|
||||
else => break,
|
||||
Some(message) = received.recv() => Some(message),
|
||||
done = pending.join_next(), if !pending.is_empty() => {
|
||||
if let Some(Err(e)) = done {
|
||||
return Err(std::io::Error::other(e));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
output.write_all(reply.to_string().as_bytes()).await?;
|
||||
@@ -72,15 +87,24 @@ where
|
||||
}
|
||||
|
||||
/// The IDE's answer to `message`, or an error response for each request in
|
||||
/// it when the IDE can't be reached.
|
||||
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope).await {
|
||||
Ok((StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
/// it when the IDE can't be reached. An answer on an event stream goes to `sent`
|
||||
/// with the messages before it.
|
||||
async fn relay(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Option<Value> {
|
||||
let failure = match post(discovery, project, message, scope, sent).await {
|
||||
Ok(Answer::Streamed { answered: true }) => return None,
|
||||
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
|
||||
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
|
||||
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
|
||||
Ok(reply) => return Some(reply),
|
||||
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
|
||||
},
|
||||
Ok((status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
|
||||
@@ -100,9 +124,23 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
enum Answer {
|
||||
Body(StatusCode, Bytes),
|
||||
/// The messages of an event stream went to the channel; `answered`: one was a response.
|
||||
Streamed {
|
||||
answered: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// POSTs `message` to the server in the discovery file; a message for the
|
||||
/// user when that fails.
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
async fn post(
|
||||
discovery: &Path,
|
||||
project: &Path,
|
||||
message: &Value,
|
||||
scope: &str,
|
||||
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
|
||||
) -> Result<Answer, String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
@@ -125,6 +163,60 @@ async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) ->
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
let status = response.status();
|
||||
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
Ok((status, body))
|
||||
let streamed = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
|
||||
let mut body = response.into_body();
|
||||
if !streamed {
|
||||
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
|
||||
return Ok(Answer::Body(status, body));
|
||||
}
|
||||
let (mut buffer, mut answered) = (Vec::new(), false);
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
|
||||
let Ok(data) = frame.into_data() else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
for data in take_events(&mut buffer) {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(&data) {
|
||||
answered |= message.get("method").is_none();
|
||||
let _ = sent.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Answer::Streamed { answered })
|
||||
}
|
||||
|
||||
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
|
||||
/// the rest. Comments and other fields are skipped.
|
||||
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
|
||||
let block: Vec<u8> = buffer.drain(..end + 2).collect();
|
||||
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
|
||||
let data: Vec<&str> = block
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data:"))
|
||||
.map(|d| d.strip_prefix(' ').unwrap_or(d))
|
||||
.collect();
|
||||
if !data.is_empty() {
|
||||
events.push(data.join("\n"));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_events;
|
||||
|
||||
#[test]
|
||||
fn events_are_taken_whole_and_comments_skipped() {
|
||||
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
|
||||
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
|
||||
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
|
||||
buffer.extend_from_slice(b":2}\n\n");
|
||||
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ impl ToolRegistry for Registry {
|
||||
match name {
|
||||
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
|
||||
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
|
||||
"sleep" => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
|
||||
Ok("slept".into())
|
||||
}
|
||||
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
|
||||
}
|
||||
}
|
||||
@@ -252,6 +256,20 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
|
||||
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
|
||||
let s = setup();
|
||||
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
|
||||
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
|
||||
let mut output = Vec::new();
|
||||
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
|
||||
let replies: Vec<Value> =
|
||||
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let answer = replies.last().unwrap();
|
||||
assert_eq!(answer["id"], 4, "{replies:?}");
|
||||
assert_eq!(answer["result"]["content"][0]["text"], "slept");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
|
||||
struct Many(Vec<PathBuf>);
|
||||
|
||||
@@ -498,7 +498,8 @@ impl ProcessManager {
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -629,7 +630,10 @@ impl ProcessManager {
|
||||
#[cfg(unix)]
|
||||
Some(Box::new(wait_readable)),
|
||||
)
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
.and_then(|_| {
|
||||
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -1120,18 +1124,35 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends what `decode` writes to `pending`, through the notice filter when there is one.
|
||||
fn emit(
|
||||
decode: impl FnOnce(&mut String),
|
||||
notice: &mut Option<ide_core::output_encoding::NoticeFilter>,
|
||||
decoded: &mut String,
|
||||
pending: &mut String,
|
||||
) {
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
decode(decoded);
|
||||
filter.filter(decoded, pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => decode(pending),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges both streams in arrival order, batching consecutive output of the same stream.
|
||||
/// `decoders` decode stdout and stderr.
|
||||
fn spawn_pump(
|
||||
rx: mpsc::Receiver<Msg>,
|
||||
on_output: OutputFn,
|
||||
entry: Arc<Entry>,
|
||||
sink: SharedSink,
|
||||
encoding: Option<String>,
|
||||
decoders: [OutputDecoder; 2],
|
||||
notice: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
|
||||
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let [mut out_dec, mut err_dec] = decoders;
|
||||
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
|
||||
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
|
||||
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
|
||||
@@ -1140,6 +1161,8 @@ fn spawn_pump(
|
||||
let mut pending = String::new();
|
||||
let mut pending_stream = Stream::Stdout;
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
// Since when a decoder holds part of a line back to tell its encoding.
|
||||
let mut held_since: Option<Instant> = None;
|
||||
let mut status: Option<Exit> = None;
|
||||
let mut drain_deadline: Option<Instant> = None;
|
||||
|
||||
@@ -1154,6 +1177,7 @@ fn spawn_pump(
|
||||
let now = Instant::now();
|
||||
let wait = [
|
||||
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
|
||||
held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
|
||||
drain_deadline.map(|t| t.saturating_duration_since(now)),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1170,14 +1194,8 @@ fn spawn_pump(
|
||||
Stream::Stdout => (&mut out_dec, &mut out_notice),
|
||||
Stream::Stderr => (&mut err_dec, &mut err_notice),
|
||||
};
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
dec.decode_into(&bytes, &mut decoded);
|
||||
filter.filter(&decoded, &mut pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => dec.decode_into(&bytes, &mut pending),
|
||||
}
|
||||
emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending);
|
||||
held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None };
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
@@ -1193,6 +1211,25 @@ fn spawn_pump(
|
||||
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
break;
|
||||
}
|
||||
// Nothing more came: show a held prompt.
|
||||
if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
|
||||
held_since = None;
|
||||
for (stream, dec, notice) in [
|
||||
(Stream::Stdout, &mut out_dec, &mut out_notice),
|
||||
(Stream::Stderr, &mut err_dec, &mut err_notice),
|
||||
] {
|
||||
if dec.holds() {
|
||||
if stream != pending_stream {
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
pending_stream = stream;
|
||||
}
|
||||
emit(|out| dec.release(out), notice, &mut decoded, &mut pending);
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
}
|
||||
|
||||
+390
-1
@@ -136,6 +136,7 @@ mod tests {
|
||||
pub enum OutputDecoder {
|
||||
Utf8(Utf8Decoder),
|
||||
Other(Box<encoding_rs::Decoder>),
|
||||
Mixed(Box<MixedDecoder>),
|
||||
}
|
||||
|
||||
impl OutputDecoder {
|
||||
@@ -147,10 +148,36 @@ impl OutputDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes
|
||||
/// what Windows' own programs write: the lines that are not UTF-8 in the system's console
|
||||
/// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16
|
||||
/// (`wsl.exe`, `cmd /u`).
|
||||
pub fn for_pipes(label: Option<&str>) -> Self {
|
||||
match Self::for_label(label) {
|
||||
Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new(
|
||||
ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page),
|
||||
))),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
|
||||
match self {
|
||||
Self::Utf8(d) => d.decode_into(bytes, out),
|
||||
Self::Other(d) => decode_with(d, bytes, out, false),
|
||||
Self::Mixed(d) => d.decode_into(bytes, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether part of a line is held back until it shows its encoding.
|
||||
pub fn holds(&self) -> bool {
|
||||
matches!(self, Self::Mixed(d) if d.holds())
|
||||
}
|
||||
|
||||
/// Decodes the held part of a line now (no more output came for a while).
|
||||
pub fn release(&mut self, out: &mut String) {
|
||||
if let Self::Mixed(d) = self {
|
||||
d.release(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +189,218 @@ impl OutputDecoder {
|
||||
decode_with(d, &[], &mut out, true);
|
||||
out
|
||||
}
|
||||
Self::Mixed(d) => d.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte
|
||||
/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…).
|
||||
pub enum NativeDecoder {
|
||||
Encoding(Box<encoding_rs::Decoder>),
|
||||
#[cfg(windows)]
|
||||
SingleByte(u32),
|
||||
}
|
||||
|
||||
impl NativeDecoder {
|
||||
/// `None` for UTF-8 and for code pages neither can decode piece by piece.
|
||||
pub fn for_code_page(cp: u32) -> Option<Self> {
|
||||
if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) {
|
||||
return (enc != encoding_rs::UTF_8)
|
||||
.then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling())));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if ide_core::output_encoding::is_single_byte_code_page(cp) {
|
||||
return Some(Self::SingleByte(cp));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) {
|
||||
match self {
|
||||
Self::Encoding(d) => decode_with(d, bytes, out, last),
|
||||
#[cfg(windows)]
|
||||
Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Line {
|
||||
/// Only ASCII so far, which reads the same in both.
|
||||
Open,
|
||||
Utf8,
|
||||
Native,
|
||||
}
|
||||
|
||||
/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo,
|
||||
/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS,
|
||||
/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command.
|
||||
///
|
||||
/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without
|
||||
/// one). The part of a line after its first non-ASCII byte is held until the line ends or
|
||||
/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes.
|
||||
///
|
||||
/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters
|
||||
/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read
|
||||
/// without NUL bytes comes.
|
||||
pub struct MixedDecoder {
|
||||
utf8: Utf8Decoder,
|
||||
native: Option<NativeDecoder>,
|
||||
line: Line,
|
||||
held: Vec<u8>,
|
||||
utf16: Option<Utf16>,
|
||||
}
|
||||
|
||||
impl MixedDecoder {
|
||||
pub fn new(native: Option<NativeDecoder>) -> Self {
|
||||
Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None }
|
||||
}
|
||||
|
||||
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let nul = bytes.contains(&0);
|
||||
match self.utf16.as_ref().map(|u| u.open || nul) {
|
||||
Some(true) => {
|
||||
if let Some(u) = &mut self.utf16 {
|
||||
u.decode(bytes, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(false) => self.utf16 = None,
|
||||
None if nul => {
|
||||
self.end_line(out);
|
||||
let mut u = Utf16::new();
|
||||
u.decode(utf16_aligned(bytes), out);
|
||||
self.utf16 = Some(u);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for piece in bytes.split_inclusive(|b| *b == b'\n') {
|
||||
let ends = piece.last() == Some(&b'\n');
|
||||
match self.line {
|
||||
Line::Utf8 => self.utf8.decode_into(piece, out),
|
||||
Line::Native => self.decode_native(piece, out),
|
||||
Line::Open => {
|
||||
let mut rest = piece;
|
||||
if self.held.is_empty() {
|
||||
let ascii = piece.iter().take_while(|b| b.is_ascii()).count();
|
||||
out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default());
|
||||
rest = &piece[ascii..];
|
||||
}
|
||||
self.held.extend_from_slice(rest);
|
||||
let decided = match std::str::from_utf8(&self.held) {
|
||||
Err(e) if e.error_len().is_some() => Some(Line::Native),
|
||||
Ok(_) if ends => Some(Line::Utf8),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(line) = decided {
|
||||
self.line = line;
|
||||
self.flush_held(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ends {
|
||||
self.line = Line::Open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_held(&mut self, out: &mut String) {
|
||||
let held = std::mem::take(&mut self.held);
|
||||
match self.line {
|
||||
Line::Native => self.decode_native(&held, out),
|
||||
Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_native(&mut self, bytes: &[u8], out: &mut String) {
|
||||
match &mut self.native {
|
||||
Some(native) => native.decode_into(bytes, out, false),
|
||||
None => self.utf8.decode_into(bytes, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the rest of the current line as it is.
|
||||
fn end_line(&mut self, out: &mut String) {
|
||||
if self.holds() {
|
||||
self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native };
|
||||
self.flush_held(out);
|
||||
}
|
||||
out.push_str(&self.utf8.finish());
|
||||
self.line = Line::Open;
|
||||
}
|
||||
|
||||
pub fn holds(&self) -> bool {
|
||||
!self.held.is_empty()
|
||||
}
|
||||
|
||||
/// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far.
|
||||
pub fn release(&mut self, out: &mut String) {
|
||||
if self.holds() {
|
||||
self.line = Line::Utf8;
|
||||
self.flush_held(out);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(mut u) = self.utf16.take() {
|
||||
decode_with(&mut u.decoder, &[], &mut out, true);
|
||||
}
|
||||
self.end_line(&mut out);
|
||||
if let Some(native) = &mut self.native {
|
||||
native.decode_into(&[], &mut out, true);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct Utf16 {
|
||||
decoder: encoding_rs::Decoder,
|
||||
/// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds.
|
||||
open: bool,
|
||||
/// An odd number of bytes so far.
|
||||
odd: bool,
|
||||
}
|
||||
|
||||
impl Utf16 {
|
||||
fn new() -> Self {
|
||||
Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false }
|
||||
}
|
||||
|
||||
fn decode(&mut self, bytes: &[u8], out: &mut String) {
|
||||
let start = out.len();
|
||||
decode_with(&mut self.decoder, bytes, out, false);
|
||||
self.odd ^= bytes.len() % 2 == 1;
|
||||
if self.odd {
|
||||
self.open = true;
|
||||
} else if out.len() > start {
|
||||
self.open = !out.ends_with('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of
|
||||
/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a
|
||||
/// character whose low byte came before (and was shown as ASCII).
|
||||
fn utf16_aligned(bytes: &[u8]) -> &[u8] {
|
||||
let (mut even, mut odd) = (0usize, 0usize);
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if *b == 0 {
|
||||
if i % 2 == 0 {
|
||||
even += 1;
|
||||
} else {
|
||||
odd += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if even > odd { &bytes[1..] } else { bytes }
|
||||
}
|
||||
|
||||
fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) {
|
||||
let mut src = bytes;
|
||||
loop {
|
||||
@@ -180,7 +415,8 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
|
||||
|
||||
#[cfg(test)]
|
||||
mod encoding_tests {
|
||||
use super::OutputDecoder;
|
||||
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
|
||||
use ide_core::output_encoding::encoding_for_code_page;
|
||||
|
||||
#[test]
|
||||
fn gbk_split_across_chunks() {
|
||||
@@ -202,4 +438,157 @@ mod encoding_tests {
|
||||
assert_eq!(out, "héllo");
|
||||
assert!(matches!(d, OutputDecoder::Utf8(_)));
|
||||
}
|
||||
|
||||
fn mixed(cp: u32) -> MixedDecoder {
|
||||
MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page")))
|
||||
}
|
||||
|
||||
fn encode(cp: u32, text: &str) -> Vec<u8> {
|
||||
let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text);
|
||||
assert!(!unmappable, "{text} in {cp}");
|
||||
bytes.into_owned()
|
||||
}
|
||||
|
||||
/// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western.
|
||||
const SAMPLES: &[(u32, &str)] = &[
|
||||
(936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"),
|
||||
(950, "系統找不到指定的路徑。"),
|
||||
(949, "지정된 경로를 찾을 수 없습니다."),
|
||||
(932, "指定されたパスが見つかりません。"),
|
||||
(866, "Системе не удается найти указанный путь."),
|
||||
(1251, "Системе не удается найти указанный путь."),
|
||||
(1252, "Le chemin d'accès spécifié est introuvable."),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn utf8_and_code_page_lines_mix_whatever_the_chunks() {
|
||||
for &(cp, native) in SAMPLES {
|
||||
let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec();
|
||||
bytes.extend(encode(cp, native));
|
||||
bytes.extend_from_slice(b"\r\n");
|
||||
bytes.extend_from_slice("ok ✓\n".as_bytes());
|
||||
bytes.extend(encode(cp, native));
|
||||
let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}");
|
||||
for split in 0..=bytes.len() {
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
d.decode_into(&bytes[..split], &mut out);
|
||||
d.decode_into(&bytes[split..], &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, expected, "code page {cp}, split at {split}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_by_byte_code_page_output() {
|
||||
for &(cp, native) in SAMPLES {
|
||||
let bytes = [encode(cp, native), b"\n".to_vec()].concat();
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
for b in &bytes {
|
||||
d.decode_into(std::slice::from_ref(b), &mut out);
|
||||
}
|
||||
assert!(!d.holds());
|
||||
assert_eq!(out, format!("{native}\n"), "code page {cp}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() {
|
||||
let mut d = mixed(936);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"Password: ", &mut out);
|
||||
assert_eq!(out, "Password: ");
|
||||
assert!(!d.holds());
|
||||
|
||||
out.clear();
|
||||
d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out);
|
||||
assert_eq!(out, "请按任意键继续. . .");
|
||||
assert!(!d.holds());
|
||||
|
||||
let mut d = mixed(936);
|
||||
out.clear();
|
||||
d.decode_into("Continue? 继续 ".as_bytes(), &mut out);
|
||||
assert_eq!(out, "Continue? ");
|
||||
assert!(d.holds());
|
||||
d.release(&mut out);
|
||||
assert_eq!(out, "Continue? 继续 ");
|
||||
d.decode_into("[y/N]\n".as_bytes(), &mut out);
|
||||
assert_eq!(out, "Continue? 继续 [y/N]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_line_split_inside_a_character_completes() {
|
||||
let mut d = mixed(949);
|
||||
let mut out = String::new();
|
||||
let bytes = "é中".as_bytes();
|
||||
d.decode_into(&bytes[..3], &mut out);
|
||||
d.release(&mut out);
|
||||
d.decode_into(&bytes[3..], &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "é中");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_code_page_has_no_mixed_decoder() {
|
||||
assert!(NativeDecoder::for_code_page(65001).is_none());
|
||||
}
|
||||
|
||||
fn utf16(text: &str) -> Vec<u8> {
|
||||
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// What `wsl.exe -l -v` writes, on a Chinese system.
|
||||
const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\
|
||||
适用于 Linux 的 Windows 子系统: 默认\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() {
|
||||
let before = " Compiling 编译\n".as_bytes();
|
||||
let wsl = utf16(WSL);
|
||||
let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat();
|
||||
let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n");
|
||||
for split in 0..=wsl.len() {
|
||||
let mut d = mixed(936);
|
||||
let mut out = String::new();
|
||||
for chunk in [before, &wsl[..split], &wsl[split..], &after] {
|
||||
d.decode_into(chunk, &mut out);
|
||||
}
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, expected, "split at {split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_with_a_byte_order_mark_or_starting_with_cjk() {
|
||||
let mut d = mixed(949);
|
||||
let mut out = String::new();
|
||||
d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out);
|
||||
d.decode_into(&utf16("中文 OK\r\n"), &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "한국어 OK\r\n中文 OK\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() {
|
||||
let mut d = MixedDecoder::new(None);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"caf\xe9\n", &mut out);
|
||||
d.decode_into(&utf16("wsl 子系统\r\n"), &mut out);
|
||||
d.decode_into("ok ✓\n".as_bytes(), &mut out);
|
||||
out.push_str(&d.finish());
|
||||
assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() {
|
||||
for cp in [437, 850, 852] {
|
||||
let mut d = mixed(cp);
|
||||
let mut out = String::new();
|
||||
d.decode_into(b"caf\x82\r\n", &mut out);
|
||||
assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console.
|
||||
fn run(command: &str) -> String {
|
||||
let (tx, events) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
|
||||
let _ = tx.lock().send(e);
|
||||
});
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = RunConfig {
|
||||
id: "t:out".into(),
|
||||
name: "t:out".into(),
|
||||
kind: "shell".into(),
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let chunks: Arc<Mutex<Vec<OutputChunk>>> = Arc::default();
|
||||
let out = chunks.clone();
|
||||
let pm = ProcessManager::new();
|
||||
let info = pm
|
||||
.start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c)))
|
||||
.expect("start");
|
||||
loop {
|
||||
match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") {
|
||||
IdeEvent::RunExited { run, .. } if run == info.id => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
chunks
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR,
|
||||
/// 437…, by the system's language), and still read right in a UTF-8 console.
|
||||
#[test]
|
||||
fn native_messages_in_the_console_code_page_are_decoded() {
|
||||
let text = run(r"dir C:\openidea-no-such-dir");
|
||||
assert!(text.trim().lines().count() >= 2, "{text:?}");
|
||||
assert!(!text.contains('\u{FFFD}'), "{text:?}");
|
||||
}
|
||||
|
||||
/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language.
|
||||
#[test]
|
||||
fn utf16_output_is_decoded() {
|
||||
let text = run("cmd /u /c echo 中文 한국어 OK");
|
||||
assert_eq!(text, "中文 한국어 OK\r\n");
|
||||
}
|
||||
@@ -108,7 +108,9 @@ fn discover_with_limit(
|
||||
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir())
|
||||
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
|
||||
{
|
||||
children.push(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -170,6 +172,19 @@ mod tests {
|
||||
assert_eq!(result.direct[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_virtual_environments_whatever_their_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
project(root, "code10", "pyvenv.cfg");
|
||||
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
|
||||
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
|
||||
project(root, "conda/pkgs/dep", "setup.py");
|
||||
let app = project(root, "app", "pyproject.toml");
|
||||
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -17,6 +17,19 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
|
||||
const SKIP_DIRS: &[&str] =
|
||||
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
|
||||
|
||||
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
|
||||
/// whatever it is named: its packages are dependencies, not modules of the project.
|
||||
/// Returns the file that marks it.
|
||||
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
|
||||
if dir.join("pyvenv.cfg").is_file() {
|
||||
Some("pyvenv.cfg")
|
||||
} else if dir.join("conda-meta").is_dir() {
|
||||
Some("conda-meta")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build files whose changes can change the detected roots.
|
||||
pub(crate) const BUILD_FILES: &[&str] = &[
|
||||
"pom.xml",
|
||||
@@ -65,7 +78,10 @@ pub fn affects_roots(rel: &Path) -> bool {
|
||||
let Some(name) = names.next() else { return false };
|
||||
BUILD_FILES.contains(&name)
|
||||
|| ROOT_NAMES.contains(&name)
|
||||
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|
||||
|| matches!(
|
||||
name,
|
||||
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
|
||||
)
|
||||
|| names.next() == Some("src")
|
||||
}
|
||||
|
||||
@@ -131,6 +147,13 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
|
||||
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
|
||||
continue;
|
||||
}
|
||||
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
|
||||
if !out.iter().any(|r| r.path == child) {
|
||||
let from = join(&child, marker);
|
||||
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
queue.push_back((child, depth + 1));
|
||||
}
|
||||
}
|
||||
@@ -505,6 +528,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_environments_are_excluded_whatever_their_name() {
|
||||
// A package in the environment's site-packages has build files of its own; as a
|
||||
// source root it would be put on PYTHONPATH and shadow the standard library.
|
||||
let dir = tree(&[
|
||||
"requirements.txt",
|
||||
"app.py",
|
||||
"code10/pyvenv.cfg",
|
||||
"code10/Lib/site-packages/pkg/pyproject.toml",
|
||||
"code10/Lib/site-packages/pkg/io/__init__.py",
|
||||
"envs/conda/conda-meta/",
|
||||
"envs/conda/lib/pkg/setup.py",
|
||||
"svc/pyproject.toml",
|
||||
"svc/env/pyvenv.cfg",
|
||||
"svc/env/lib/python3.12/site-packages/dep/setup.py",
|
||||
]);
|
||||
assert_eq!(
|
||||
summary(&detect(dir.path())),
|
||||
vec![
|
||||
"[email protected]",
|
||||
"code10=Excluded@code10/pyvenv.cfg",
|
||||
"envs/conda=Excluded@envs/conda/conda-meta",
|
||||
"svc=Source@svc/pyproject.toml",
|
||||
"svc/env=Excluded@svc/env/pyvenv.cfg",
|
||||
]
|
||||
);
|
||||
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_ecosystem_wins_for_a_shared_folder() {
|
||||
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! project-relative and `/`-separated; `""` is the project root itself.
|
||||
|
||||
mod detect;
|
||||
pub(crate) use detect::BUILD_FILES;
|
||||
pub(crate) use detect::{BUILD_FILES, virtual_env_marker};
|
||||
mod gradle;
|
||||
mod java;
|
||||
mod maven;
|
||||
|
||||
@@ -71,7 +71,7 @@ On your own messages you can **Edit** (the messages after it are replaced; files
|
||||
|
||||
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
|
||||
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and the Command Execution Policy doesn't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and command permissions don't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
|
||||
|
||||
### Review the changes
|
||||
|
||||
|
||||
+14
-1
@@ -87,6 +87,8 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
|
||||
| Code | `get_symbol_info`, `find_references`, `rename_refactoring`, `reformat_file` |
|
||||
| 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:
|
||||
@@ -98,12 +100,23 @@ Notes on what they do:
|
||||
- `apply_patch` accepts the Codex `apply_patch` format and unified diffs. Paths must stay inside the project.
|
||||
- `git_diff` compares files on disk, so unsaved editor text is not included.
|
||||
|
||||
## Commands in the Run tool window
|
||||
|
||||
`run_command` runs a shell command line in a tab of the Run tool window, with the shell of **Settings › Tools › Terminal**. The agent names the tab and OpenIDEA shows it as `_agent_<name>`, so you see the output as it comes and can type into the command, rerun it or stop it like any run.
|
||||
|
||||
As JetBrains IDEs do, the command runs on pipes, not a terminal: programs print plain lines instead of progress bars and redraws. The agent gets that output cleaned of escape sequences and cut to 2000 lines (`maxLinesCount`); beyond that the end stays, or the start or both ends with `truncateMode`. The tab's raw output log stays complete.
|
||||
|
||||
- **One tab per name.** A name whose command finished runs the next command in the same tab. A name whose command is still running is refused, with that command and its last output: the agent has to stop it with `stop_run` first or pick another name. Nothing is stopped behind your back.
|
||||
- **Following a command up.** `wait_for_run` waits for the command to end or for a pattern in its output (a server's "listening" line), `read_run_output` reads the output from any point, `get_run_status` tells whether it is running, succeeded, failed, crashed or was stopped by the agent or by you, and `send_run_input` writes text and Enter to its input; Ctrl+C stops it and Ctrl+D ends its input.
|
||||
- **You can step in.** What you type into the tab's input line, a rerun, a Stop and closing the tab are reported to the agent with their time. A wait ends at once, and the agent is advised to stop what it is doing, tell you it received the intervention and wait for your confirmation.
|
||||
- **Finished tabs are greyed out.** Beyond **Finished agent tabs to show** (1 by default) the oldest finished ones move to an archive at the end of the tab strip, shown as **Archived (n)**. Beyond **Archived agent tabs to keep** (999) the oldest archived ones are closed. Running commands are never archived or closed. Both settings are on **Settings › Agents › IDE MCP Server**.
|
||||
|
||||
Plugins can add more tools; they appear in the same list. A plugin tool marked as destructive asks for confirmation the same way.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Untrusted projects** expose only the read-only tools. Tools that write files, rename, reformat, build or run need a project you have trusted.
|
||||
- **Running and building ask first.** `execute_run_configuration` and `build_project` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- **Running and building ask first.** `execute_run_configuration`, `build_project` and `run_command` show a dialog in OpenIDEA naming the client, the project, the tool and what will run. Choose **Allow** or **Deny**; several requests queue up.
|
||||
- Tools that write refuse paths outside the project.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
|
||||
### 查看改动
|
||||
|
||||
|
||||
+14
-1
@@ -86,6 +86,8 @@ stdio 配置大致如下(路径以你机器上的为准):
|
||||
| 代码 | `get_symbol_info`、`find_references`、`rename_refactoring`、`reformat_file` |
|
||||
| 问题 | `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` |
|
||||
|
||||
几点说明:
|
||||
@@ -97,12 +99,23 @@ stdio 配置大致如下(路径以你机器上的为准):
|
||||
- `apply_patch` 接受 Codex 的 `apply_patch` 格式和 unified diff,路径必须在项目内。
|
||||
- `git_diff` 比较的是磁盘上的文件,不包含编辑器中尚未保存的内容。
|
||||
|
||||
## 在运行工具窗口中执行命令
|
||||
|
||||
`run_command` 在运行工具窗口的一个标签页中,用 **设置 › 工具 › 终端** 中配置的 shell 执行一条命令行。标签名由智能体指定,OpenIDEA 显示为 `_agent_<名称>`。输出实时可见,你也可以像对待其他运行一样向命令输入、重新运行或停止它。
|
||||
|
||||
和 JetBrains 的 IDE 一样,命令通过管道运行,而不是在终端里运行:程序输出的是普通文本行,不会有进度条和屏幕重绘。智能体拿到的输出会去掉控制字符,并截断到 2000 行(`maxLinesCount`);超出部分默认保留结尾,也可以用 `truncateMode` 改为保留开头或两头。标签页的原始输出日志保持完整。
|
||||
|
||||
- **一个名称对应一个标签。** 名称对应的命令已结束时,下一条命令在同一标签中运行。命令仍在运行时会被拒绝,并返回该命令和它最后的输出:智能体必须先用 `stop_run` 停止它,或者换一个名称。任何进程都不会被悄悄停掉。
|
||||
- **跟进命令。** `wait_for_run` 等待命令结束,或等待输出中出现指定文本(例如服务器的“正在监听”一行);`read_run_output` 从任意位置读取输出;`get_run_status` 报告命令是在运行、成功、失败、崩溃,还是被智能体或你停止;`send_run_input` 向命令的输入写入文本和 Enter,Ctrl+C 会停止命令,Ctrl+D 结束它的输入。
|
||||
- **你可以随时干预。** 你在标签输入行中输入的内容、重新运行、停止和关闭标签,都会连同时间一起告诉智能体。正在进行的等待会立即返回,并建议智能体停下手头的工作,回复你已收到干预,等你确认后再继续。
|
||||
- **已结束的标签会置灰。** 已结束的标签超过 **显示的已结束智能体标签数**(默认 1)时,最旧的会移入标签栏末尾的归档,显示为 **归档(n)**。归档超过 **保留的已归档智能体标签数**(默认 999)时,最旧的会被关闭。正在运行的命令永远不会被归档或关闭。这两项设置位于 **设置 › 智能体 › IDE MCP 服务器**。
|
||||
|
||||
插件可以添加更多工具,它们会出现在同一个列表中。标记为破坏性操作的插件工具同样会先请求确认。
|
||||
|
||||
## 安全
|
||||
|
||||
- **未受信任的项目** 只开放只读工具。写文件、重命名、重新格式化、构建或运行的工具都要求项目已被信任。
|
||||
- **运行和构建会先询问。** `execute_run_configuration` 和 `build_project` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- **运行和构建会先询问。** `execute_run_configuration`、`build_project` 和 `run_command` 会在 OpenIDEA 中弹出对话框,说明是哪个客户端、哪个项目、哪个工具以及要运行什么,选择 **允许** 或 **拒绝**;多个请求会排队。
|
||||
- 会写入的工具拒绝项目之外的路径。
|
||||
|
||||
## 限制
|
||||
|
||||
@@ -22,6 +22,37 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
|
||||
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
gtk::Clipboard::get(>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,6 +140,15 @@ pub fn mcp_ui_ready(window: Window, state: State<'_, AppState>, ready: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user closed the Run tool window tab of an agent command (`run_command`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_agent_run_closed(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
state.host.mcp_agent_runs.closed(&project.root, &name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_respond(
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
file_dialog::gtk_pick_paths,
|
||||
mcp::mcp_ui_ready,
|
||||
mcp::mcp_respond,
|
||||
mcp::mcp_agent_run_closed,
|
||||
mcp::mcp_server_status,
|
||||
mcp::mcp_set_open_editors,
|
||||
mcp::mcp_set_focused_editor,
|
||||
@@ -187,6 +188,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_read_text,
|
||||
fs::fs_open_with_system,
|
||||
clipboard::clipboard_write_text,
|
||||
clipboard::clipboard_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_image,
|
||||
|
||||
+18
-1
@@ -17,6 +17,8 @@ export const commands = {
|
||||
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
|
||||
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
|
||||
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
|
||||
/** The user closed the Run tool window tab of an agent command (`run_command`). */
|
||||
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
|
||||
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
|
||||
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
|
||||
mcpSetFocusedEditor: (editor: {
|
||||
@@ -280,6 +282,12 @@ export const commands = {
|
||||
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
|
||||
/** Writes text locally, without WebView user-activation requirements. */
|
||||
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
|
||||
/**
|
||||
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
* text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
*/
|
||||
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
|
||||
/** Absolute paths on the clipboard, empty when it holds no file list. */
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
@@ -7183,6 +7191,10 @@ export type McpServerSettings = {
|
||||
* (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
*/
|
||||
runWithoutConfirmation?: boolean,
|
||||
/** Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived. */
|
||||
agentTabLimit?: number,
|
||||
/** Archived agent tabs kept; older ones are closed. */
|
||||
agentArchiveLimit?: number,
|
||||
};
|
||||
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
@@ -7204,7 +7216,12 @@ export type McpServerStatus = {
|
||||
/** Transport of an MCP server configured in Settings › Agents › MCP Servers. */
|
||||
export type McpTransport = "stdio" | "http" | "sse";
|
||||
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null };
|
||||
/**
|
||||
* What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
* line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
* started or failed to.
|
||||
*/
|
||||
export type McpUiRequest = { type: "confirm"; client: string; tool: string; subject: string; project: string; detail: string | null } | { type: "openFile"; path: IpcPath; line: number | null; column: number | null } | { type: "agentRun"; name: string; command: string; cwd: IpcPath; env: { [key in string]: string } };
|
||||
|
||||
export type MemoryCategory =
|
||||
/** The backend and its WebView processes. */
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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,8 +25,13 @@
|
||||
* 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
|
||||
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* (`deferredScanner`), 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,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type ChangeSet,
|
||||
type Extension,
|
||||
type Transaction,
|
||||
} from "@codemirror/state";
|
||||
@@ -369,11 +375,55 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
|
||||
|
||||
interface DocsView {
|
||||
decorations: DecorationSet;
|
||||
/** Every doc comment, rendered or not. */
|
||||
/** Every doc comment, rendered or not, in document order. */
|
||||
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: [] };
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered comments by their source, shared by every editor, least recently used first.
|
||||
@@ -442,7 +492,7 @@ function build(state: EditorState): DocsView {
|
||||
),
|
||||
);
|
||||
}
|
||||
return { decorations: Decoration.set(decorations), comments };
|
||||
return { decorations: Decoration.set(decorations), comments, stale: false };
|
||||
}
|
||||
|
||||
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
|
||||
@@ -476,7 +526,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 `passiveScanner` rescans once typing pauses; meanwhile the comments
|
||||
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
|
||||
* found are mapped through the edits. Empty while the file renders comments: `docsField`
|
||||
* has them then.
|
||||
*/
|
||||
@@ -487,32 +537,29 @@ 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;
|
||||
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),
|
||||
}));
|
||||
return tr.docChanged ? mapComments(value, tr.changes) : value;
|
||||
},
|
||||
});
|
||||
|
||||
/** Scans a file that renders no comments once the edits and the parser have paused. */
|
||||
const passiveScanner = ViewPlugin.fromClass(
|
||||
/**
|
||||
* 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(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(readonly view: EditorView) {}
|
||||
update(update: ViewUpdate) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
cancel() {
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
@@ -524,8 +571,8 @@ const passiveScanner = ViewPlugin.fromClass(
|
||||
},
|
||||
);
|
||||
|
||||
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
|
||||
const PASSIVE_SCAN_DELAY = 300;
|
||||
/** How long edits and parsing must pause before the file is scanned again. */
|
||||
const RESCAN_DELAY = 300;
|
||||
|
||||
/** Every doc comment of the file, as last found. */
|
||||
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
|
||||
@@ -587,13 +634,26 @@ const docsField = StateField.define<DocsView>({
|
||||
create: build,
|
||||
update(value, tr) {
|
||||
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
|
||||
const rebuild =
|
||||
tr.docChanged ||
|
||||
if (
|
||||
tr.reconfigured ||
|
||||
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;
|
||||
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;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
|
||||
});
|
||||
@@ -838,7 +898,7 @@ export const renderedDocsExtension: Extension = [
|
||||
flipped,
|
||||
docsField,
|
||||
passiveDocs,
|
||||
passiveScanner,
|
||||
deferredScanner,
|
||||
hovered,
|
||||
hoverTracker,
|
||||
showSourceAtCaret,
|
||||
|
||||
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
|
||||
] as MenuItem[])
|
||||
: ([
|
||||
{ kind: "separator" },
|
||||
openInMenu(slot.model.path),
|
||||
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
|
||||
{ kind: "separator" },
|
||||
localHistoryMenu(slot.model.path),
|
||||
gitMenu(slot.model.path),
|
||||
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
|
||||
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
|
||||
function openInMenu(path: string): MenuItem {
|
||||
function openInMenu(path: string, line?: number): MenuItem {
|
||||
return {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
@@ -1971,7 +1971,7 @@ function openInMenu(path: string): MenuItem {
|
||||
: []),
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
...menuGroupItems("openIn", { paths: [path], line }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { structure } from "@/editor/structure";
|
||||
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
|
||||
if (view.state.readOnly) return;
|
||||
let text: string;
|
||||
try {
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
text = await readClipboardPlainText();
|
||||
} catch {
|
||||
hint("Cannot read the clipboard");
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
|
||||
* Settings › Tools › Agents › Command Permissions: 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 Execution Policy">
|
||||
<section className={styles.body} aria-label="Command Permissions">
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
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.
|
||||
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.
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Allowed Commands (Whitelist)</T>
|
||||
<T>Allowed Commands</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 (Blacklist)</T>
|
||||
<T>Blocked Commands</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 Execution Policy sub-page", () => {
|
||||
describe("Command Permissions sub-page", () => {
|
||||
it("registers under Agents group in settings", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
try {
|
||||
@@ -40,7 +40,7 @@ describe("Command Execution Policy 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 Execution Policy");
|
||||
expect(policyNode?.title).toBe("Command Permissions");
|
||||
// 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 Execution Policy sub-page", () => {
|
||||
</FormWrapper>,
|
||||
);
|
||||
|
||||
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
|
||||
const policy = screen.getByRole("region", { name: "Command Permissions" });
|
||||
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 Execution Policy",
|
||||
title: "Command Permissions",
|
||||
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 Execution Policy. */
|
||||
/** Settings › Agents › Command Permissions. */
|
||||
export const POLICY_PAGE = "agents.policy";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -11,6 +11,7 @@ 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);
|
||||
@@ -50,8 +51,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" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
if (command === "paste") {
|
||||
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
}
|
||||
document.execCommand(command);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -48,7 +49,7 @@ function dataSourceItems(): MenuItem[] {
|
||||
async function sourceFromClipboard() {
|
||||
let text = "";
|
||||
try {
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
text = await readClipboardPlainText();
|
||||
} catch {
|
||||
// Unreadable clipboard: reported below like an unknown one.
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
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() }));
|
||||
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,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<object>()),
|
||||
commands: { mcpRespond: mocks.respond },
|
||||
@@ -75,4 +83,18 @@ 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,5 +1,6 @@
|
||||
/** 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";
|
||||
|
||||
@@ -43,6 +44,17 @@ 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 { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard.readText();
|
||||
const text = await readClipboardPlainText();
|
||||
setRows((r) => pasteRows(r, text));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
|
||||
@@ -12,6 +12,32 @@
|
||||
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, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import type { RunTargetValid } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
|
||||
import { useAgentRunSettings, useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { EmptyState, Icon, IconButton, Menu, 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,6 +39,8 @@ 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";
|
||||
@@ -273,6 +275,8 @@ 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"
|
||||
@@ -308,26 +312,74 @@ 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(sessions, runTargets),
|
||||
...sessionItems(shown, 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={"session" in current ? current.session.id : current.trace.id}
|
||||
activeId={activeId}
|
||||
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 { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard?.readText().then((text) => {
|
||||
void readClipboardPlainText().then((text) => {
|
||||
if (!text.trim()) return;
|
||||
const idx = text.indexOf("=");
|
||||
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** 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: "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 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 { onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
|
||||
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
|
||||
import {
|
||||
menuGroupItems,
|
||||
@@ -94,6 +94,7 @@ 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";
|
||||
|
||||
@@ -636,6 +637,16 @@ 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",
|
||||
@@ -736,6 +747,11 @@ 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,6 +16,7 @@ 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;
|
||||
@@ -83,7 +84,7 @@ export function closeStackTrace(id: string) {
|
||||
async function openDialog() {
|
||||
let clip = "";
|
||||
try {
|
||||
clip = (await navigator.clipboard?.readText()) ?? "";
|
||||
clip = await readClipboardPlainText();
|
||||
} catch {
|
||||
// clipboard unavailable or denied: start empty
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ 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";
|
||||
@@ -1473,10 +1474,17 @@ export async function closeSessionTab(sessionId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closeSession(sessionId: string) {
|
||||
/**
|
||||
* 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 } = {}) {
|
||||
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,7 +18,13 @@ import {
|
||||
type ProjectStructureValid,
|
||||
} from "@/platform/languageSettings";
|
||||
import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings";
|
||||
import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings";
|
||||
import {
|
||||
DEFAULT_AGENT_RUNS,
|
||||
DEFAULT_WORKFLOW,
|
||||
validateAgentRuns,
|
||||
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 {
|
||||
@@ -106,7 +112,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 };
|
||||
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean; agentTabLimit: number; agentArchiveLimit: number };
|
||||
}
|
||||
|
||||
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
|
||||
@@ -197,7 +203,12 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
defaults: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
mcpServer: { enabled: false, runWithoutConfirmation: false },
|
||||
mcpServer: {
|
||||
enabled: false,
|
||||
runWithoutConfirmation: false,
|
||||
agentTabLimit: DEFAULT_AGENT_RUNS.tabLimit,
|
||||
agentArchiveLimit: DEFAULT_AGENT_RUNS.archiveLimit,
|
||||
},
|
||||
...DEFAULT_WORKFLOW,
|
||||
};
|
||||
|
||||
@@ -370,6 +381,8 @@ 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,6 +2,7 @@
|
||||
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";
|
||||
@@ -134,6 +135,22 @@ 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 the Command Execution Policy doesn't apply",
|
||||
hint: "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ 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;
|
||||
@@ -82,7 +83,7 @@ function userName(): string {
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
return await readClipboardPlainText();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -142,6 +142,27 @@ 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();
|
||||
@@ -259,6 +280,25 @@ 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,7 +14,11 @@
|
||||
* - 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.
|
||||
* - 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.
|
||||
*
|
||||
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
|
||||
* input events for keys xterm already sent, and input with no IME keydown behind it.
|
||||
@@ -28,6 +32,8 @@
|
||||
*/
|
||||
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"]);
|
||||
|
||||
@@ -62,6 +68,9 @@ 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) {
|
||||
@@ -69,6 +78,8 @@ 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) {
|
||||
@@ -90,6 +101,8 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
const onKeyUp = () => {
|
||||
imeKey = false;
|
||||
imeDown = false;
|
||||
modifierDown = false;
|
||||
keyDown = false;
|
||||
};
|
||||
|
||||
const onInput = (e: InputEvent) => {
|
||||
@@ -97,7 +110,8 @@ 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).
|
||||
if (!imeDown) return;
|
||||
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
|
||||
if (!imeDown && !(modifierDown && !keyDown)) return;
|
||||
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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());
|
||||
@@ -525,3 +526,28 @@ 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,6 +1,7 @@
|
||||
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
|
||||
import { pasteClipboardFiles, readClipboardPlainText, 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 {
|
||||
@@ -120,6 +121,15 @@ 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 =
|
||||
@@ -130,7 +140,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
)
|
||||
: null;
|
||||
const text = target?.remote
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
? await readClipboardPlainText().catch(() => "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
|
||||
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -2627,6 +2628,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
|
||||
|
||||
if (!commit)
|
||||
return (
|
||||
@@ -2667,7 +2669,13 @@ 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}>
|
||||
<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.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
|
||||
<IconButton
|
||||
icon="diff"
|
||||
|
||||
@@ -62,6 +62,7 @@ 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 = {
|
||||
@@ -497,7 +498,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 navigator.clipboard?.readText().catch(() => undefined);
|
||||
const text = await readClipboardPlainText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,7 +12,8 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
import { loadLogDetails } from "../logDetails";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { loadLogDetails, useCommitDetails } from "../logDetails";
|
||||
import { useVcs } from "../store";
|
||||
import { useGitLog } from "../logStore";
|
||||
const commit = (id: string): Commit => ({
|
||||
@@ -92,3 +93,38 @@ 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,7 +373,8 @@ export function registerVcsActions(): () => void {
|
||||
icon: "diff",
|
||||
when: VCS,
|
||||
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
|
||||
keyWhen: "!editorTextFocus",
|
||||
// In the Git Log's changed files it shows the commit's diff instead.
|
||||
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
|
||||
run: async ({ arg, ctx }) => {
|
||||
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
|
||||
const line =
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -285,7 +286,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 navigator.clipboard?.readText()) ?? "";
|
||||
clipboard = await readClipboardPlainText();
|
||||
} catch {
|
||||
return info("The clipboard could not be read");
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
|
||||
<div
|
||||
ref={body}
|
||||
className="vcsDiffDialogBody"
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
|
||||
// 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 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
registerTitleItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerVcsActions } from "./actions";
|
||||
@@ -50,6 +50,7 @@ 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 {
|
||||
@@ -231,6 +232,8 @@ export function activate(): () => void {
|
||||
(signOff) => useCommit.setState({ signOff }),
|
||||
),
|
||||
registerVcsActions(),
|
||||
registerActions(openOnRemoteActions),
|
||||
registerOpenOnRemote(),
|
||||
registerHistoryActions(),
|
||||
registerLogPaneActions(),
|
||||
activateSettingsPages(),
|
||||
|
||||
@@ -126,20 +126,28 @@ 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 key = `${repo}\0${commit.id}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void loadLogDetails(commit, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) setResult({ key, details, error: false });
|
||||
if (!controller.signal.aborted) setResult({ target, details, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
|
||||
if (!controller.signal.aborted) setResult({ target, details: null, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, enabled]);
|
||||
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
|
||||
// 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 };
|
||||
}
|
||||
@@ -152,6 +152,7 @@ export function registerLogPaneActions(): () => void {
|
||||
icon: "diff",
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
keyWhen: "vcsLogChangesFocus",
|
||||
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -165,7 +165,51 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
|
||||
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */
|
||||
/** 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. */
|
||||
export function fileCount(n: number): string {
|
||||
return n === 1 ? tx`${n} file` : tx`${n} files`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard.readText();
|
||||
text = await readClipboardPlainText();
|
||||
} else {
|
||||
let file = path ?? null;
|
||||
if (!file) {
|
||||
|
||||
@@ -7,6 +7,19 @@ export async function writeClipboardText(text: string): Promise<void> {
|
||||
else await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard's plain text, read natively where the backend can (macOS, Linux), else
|
||||
* through the WebView. A Paste from a menu has no paste event to read, and WebKitGTK's
|
||||
* `navigator.clipboard.readText()` gives no text there. Rejects when the WebView refuses.
|
||||
*/
|
||||
export async function readClipboardPlainText(): Promise<string> {
|
||||
if (isTauri()) {
|
||||
const text = await call(commands.clipboardText()).catch(() => null);
|
||||
if (text != null) return text;
|
||||
}
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
}
|
||||
|
||||
type FormatFiles = (paths: string[]) => string;
|
||||
const rawPaths: FormatFiles = (paths) => paths.join("\n");
|
||||
|
||||
@@ -15,9 +28,7 @@ export async function readClipboardText(
|
||||
transfer?: DataTransfer | null,
|
||||
formatFiles: FormatFiles = rawPaths,
|
||||
): Promise<string> {
|
||||
const text = transfer
|
||||
? transfer.getData("text/plain")
|
||||
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
|
||||
const text = transfer ? transfer.getData("text/plain") : await readClipboardPlainText().catch(() => "");
|
||||
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
|
||||
let paths: string[];
|
||||
try {
|
||||
|
||||
@@ -50,7 +50,7 @@ const shared = [
|
||||
["code.quickDocumentation", "db.quickDoc", "debug.setValue", "vcs.changelist.edit"],
|
||||
["code.reformat", "project.reformatCode", "db.reformatValue"],
|
||||
["code.optimizeImports", "project.optimizeImports"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff", "vcs.log.changes.showDiff"],
|
||||
["code.moveStatementDown", "find.field.recentSearches", "find.popup.recentSearches"],
|
||||
["code.foldAll", "project.collapseAll"],
|
||||
["refactor.rename", "project.rename", "db.rename", "vcs.changelist.edit", "vcs.log.editMessage", "vcs.shelf.rename"],
|
||||
|
||||
@@ -303,6 +303,7 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Meta+Alt+Z"],
|
||||
"vcs.addToVcs": ["Meta+Alt+A"],
|
||||
"vcs.showDiff": ["Meta+D"],
|
||||
"vcs.log.changes.showDiff": ["Meta+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
@@ -297,6 +297,7 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Ctrl+Alt+Z"],
|
||||
"vcs.addToVcs": ["Ctrl+Alt+A"],
|
||||
"vcs.showDiff": ["Ctrl+D"],
|
||||
"vcs.log.changes.showDiff": ["Ctrl+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
@@ -300,6 +300,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"vcs.rollback": [],
|
||||
"vcs.addToVcs": [],
|
||||
"vcs.showDiff": [],
|
||||
"vcs.log.changes.showDiff": [],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Alt+F5"],
|
||||
"vcs.prevChange": ["Alt+Shift+F5"],
|
||||
|
||||
@@ -1087,7 +1087,7 @@
|
||||
"New tasks start": "New tasks start",
|
||||
"Without asking for approval": "Without asking for approval",
|
||||
"Asking for approval": "Asking for approval",
|
||||
"Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply": "Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply",
|
||||
"Without asking means the agent's bypass or full-access mode, where command permissions don't apply": "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
|
||||
"Conversation": "Conversation",
|
||||
"Fold a finished turn's work": "Fold a finished turn's work",
|
||||
"Reasoning and tool calls before the reply fold into one line under its message": "Reasoning and tool calls before the reply fold into one line under its message",
|
||||
@@ -1456,5 +1456,18 @@
|
||||
"Dependencies": "Dependencies",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.",
|
||||
"No credential store": "No credential store",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you."
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you.",
|
||||
"Open on Git Remote": "Open on Git Remote",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "Opens the file or directory on the web page of its repository's remote.",
|
||||
"This file has no page on a Git remote": "This file has no page on a Git remote",
|
||||
"Run Agent Command": "Run Agent Command",
|
||||
"Runs `{ name, command, cwd, env }` from the MCP server's `run_command` in the agent tab `name`; throws when it did not start.": "Runs `{ name, command, cwd, env }` from the MCP server's `run_command` in the agent tab `name`; throws when it did not start.",
|
||||
"Archived agent tabs: {count}": "Archived agent tabs: {count}",
|
||||
"Archived agent tabs": "Archived agent tabs",
|
||||
"Close All Archived Tabs": "Close All Archived Tabs",
|
||||
"Finished agent tabs to show": "Finished agent tabs to show",
|
||||
"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.": "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.",
|
||||
"Archived agent tabs to keep": "Archived agent tabs to keep",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
|
||||
"Archived ({count})": "Archived ({count})"
|
||||
}
|
||||
@@ -396,11 +396,11 @@
|
||||
"Custom executable": "自定义可执行文件",
|
||||
"Found on PATH": "已在 PATH 中找到",
|
||||
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "留空以从 PATH 查找可执行文件。更改对新会话生效。",
|
||||
"Command Execution Policy": "命令执行策略",
|
||||
"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.": "允许的命令无需确认即可运行:条目匹配该命令本身或带更多参数的该命令。含命令替换、重定向或变量赋值的命令总会询问。阻止的命令会被拒绝,且优先于允许的命令;可使用 * 和 ? 通配符。",
|
||||
"Allowed Commands (Whitelist)": "允许的命令(白名单)",
|
||||
"Blocked Commands (Blacklist)": "阻止的命令(黑名单)",
|
||||
"e.g. cargo test*": "例如 cargo test*",
|
||||
"Command Permissions": "命令权限",
|
||||
"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": "允许的命令",
|
||||
"Blocked Commands": "阻止的命令",
|
||||
"e.g. cargo test": "例如 cargo test",
|
||||
"e.g. rm -rf *": "例如 rm -rf *",
|
||||
"Add allowed command": "添加允许的命令",
|
||||
"Add blocked command": "添加阻止的命令",
|
||||
@@ -7446,7 +7446,7 @@
|
||||
"New tasks start": "新任务开始时",
|
||||
"Without asking for approval": "不请求批准",
|
||||
"Asking for approval": "请求批准",
|
||||
"Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply": "不请求批准即使用智能体的绕过权限或完全访问模式,此时命令执行策略不生效",
|
||||
"Without asking means the agent's bypass or full-access mode, where command permissions don't apply": "不请求批准即使用智能体的绕过权限或完全访问模式,此时命令权限不生效",
|
||||
"Conversation": "对话",
|
||||
"Fold a finished turn's work": "折叠已完成回合的过程",
|
||||
"Reasoning and tool calls before the reply fold into one line under its message": "回复之前的思考和工具调用折叠成一行,显示在对应消息下方",
|
||||
@@ -7458,7 +7458,7 @@
|
||||
"No checksum provided: the download can't be verified.": "未提供校验和:无法校验下载的文件。",
|
||||
"Sign In": "登录",
|
||||
"New Tasks": "新任务",
|
||||
"Coding Agents": "编程 Agent",
|
||||
"Coding Agents": "编程智能体",
|
||||
"Install coding agents and configure how they launch, chat and connect to the IDE.": "安装编程 Agent,并配置其启动方式、聊天行为以及与 IDE 的连接。",
|
||||
"Fork Agent Task": "Fork Agent 任务",
|
||||
"Couldn't fork the task": "无法 Fork 任务",
|
||||
@@ -7854,5 +7854,18 @@
|
||||
"All Interpreters": "所有解释器",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "配置项目解释器及其软件包、IDE 已知的所有解释器,以及 Ruff。",
|
||||
"No credential store": "没有凭据存储",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。"
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。",
|
||||
"Open on Git Remote": "在 Git 远程上打开",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "在仓库远程的网页上打开该文件或目录。",
|
||||
"This file has no page on a Git remote": "该文件在 Git 远程上没有对应页面",
|
||||
"Run Agent Command": "运行智能体命令",
|
||||
"Runs `{ name, command, cwd, env }` from the MCP server's `run_command` in the agent tab `name`; throws when it did not start.": "在智能体标签 `name` 中运行 MCP 服务器 `run_command` 传来的 `{ name, command, cwd, env }`;未能启动时抛出错误。",
|
||||
"Archived agent tabs: {count}": "已归档的智能体标签:{count}",
|
||||
"Archived agent tabs": "已归档的智能体标签",
|
||||
"Close All Archived Tabs": "关闭所有已归档标签",
|
||||
"Finished agent tabs to show": "显示的已结束智能体标签数",
|
||||
"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.": "智能体运行的命令以 _agent_ 标签显示在运行工具窗口中。已结束的标签超过这个数量时,最旧的会移入标签栏末尾的归档。正在运行的命令永远不会被归档。",
|
||||
"Archived agent tabs to keep": "保留的已归档智能体标签数",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
|
||||
"Archived ({count})": "归档({count})"
|
||||
}
|
||||
@@ -165,6 +165,14 @@ export interface AgentSecurityValid {
|
||||
blockedCommands: string[];
|
||||
}
|
||||
|
||||
/** Agents › IDE MCP Server: the Run tool window's agent tabs (`run_command`). */
|
||||
export interface AgentRunSettingsValid {
|
||||
/** Finished agent tabs shown; older ones go to the archive. */
|
||||
tabLimit: number;
|
||||
/** Archived agent tabs kept; older ones are closed. */
|
||||
archiveLimit: number;
|
||||
}
|
||||
|
||||
export interface WorkflowSettings extends RemoteSettings {
|
||||
vcs: VcsSettingsValid;
|
||||
console: ConsoleSettingsValid;
|
||||
@@ -183,6 +191,8 @@ export const WORKFLOW_LIMITS = {
|
||||
bufferKb: { min: 16, max: 1_048_576 },
|
||||
tooltipDelay: { min: 0, max: 10_000 },
|
||||
terminationTimeoutMs: { min: 0, max: 600_000 },
|
||||
agentTabLimit: { min: 0, max: 1000 },
|
||||
agentArchiveLimit: { min: 0, max: 100_000 },
|
||||
terminalFontSize: { min: 6, max: 72 },
|
||||
terminalLineHeight: { min: 0.5, max: 3 },
|
||||
terminalColumnWidth: { min: 0.5, max: 2 },
|
||||
@@ -542,6 +552,18 @@ export function validateAgentSecurity(raw: unknown): AgentSecurityValid {
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_RUNS: AgentRunSettingsValid = { tabLimit: 1, archiveLimit: 999 };
|
||||
|
||||
/** From `mcpServer` (Agents › IDE MCP Server). */
|
||||
export function validateAgentRuns(raw: unknown): AgentRunSettingsValid {
|
||||
const v = obj(raw);
|
||||
const d = DEFAULT_AGENT_RUNS;
|
||||
return {
|
||||
tabLimit: num(v.agentTabLimit, d.tabLimit, WORKFLOW_LIMITS.agentTabLimit, true),
|
||||
archiveLimit: num(v.agentArchiveLimit, d.archiveLimit, WORKFLOW_LIMITS.agentArchiveLimit, true),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateWorkflow(raw: unknown): WorkflowSettings {
|
||||
const root = obj(raw);
|
||||
return {
|
||||
@@ -619,3 +641,14 @@ export const EXTERNAL_TOOL_MACROS: ReadonlyArray<{ name: string; description: st
|
||||
{ name: "$SelectedText$", description: "Text selected in the editor" },
|
||||
{ name: "$Prompt$", description: "Asks for a value when the tool runs" },
|
||||
];
|
||||
|
||||
/** Agents › IDE MCP Server limits on the Run tool window's agent tabs. */
|
||||
export function agentRunSettings(): AgentRunSettingsValid {
|
||||
return validateAgentRuns(obj(useProject.getState().settings).mcpServer);
|
||||
}
|
||||
|
||||
/** React hook over {@link agentRunSettings}. */
|
||||
export function useAgentRunSettings(): AgentRunSettingsValid {
|
||||
const raw = useProject((p) => obj(p.settings).mcpServer);
|
||||
return validateAgentRuns(raw);
|
||||
}
|
||||
@@ -88,6 +88,9 @@
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.dimmed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.closeSlot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
+3
-1
@@ -40,6 +40,8 @@ export interface TabItem {
|
||||
/** CSS colour the tab background is tinted with (IDEA's data source / file colours). */
|
||||
tint?: string;
|
||||
italic?: boolean;
|
||||
/** Greyed out, e.g. a run tab whose process has ended. */
|
||||
dimmed?: boolean;
|
||||
tooltip?: string;
|
||||
/** Trailing content (counts, badges). */
|
||||
trailing?: ReactNode;
|
||||
@@ -431,7 +433,7 @@ export function Tabs({
|
||||
aria-controls={panelIdPrefix ? `${panelIdPrefix}-panel-${item.id}` : undefined}
|
||||
aria-selected={selected}
|
||||
tabIndex={selected || (!activeId && index === 0) ? 0 : -1}
|
||||
className={styles.tabButton}
|
||||
className={clsx(styles.tabButton, item.dimmed && styles.dimmed)}
|
||||
style={item.color ? { color: item.color } : undefined}
|
||||
onClick={(e) => {
|
||||
// Shift+click is mouse-only: a keyboard Enter with Shift held still selects.
|
||||
|
||||
@@ -121,8 +121,10 @@ it("selects only the pane's text on primary+A", () => {
|
||||
const event = fireEvent.keyDown(pane, { key: "a", ctrlKey: true, metaKey: true });
|
||||
expect(event).toBe(false);
|
||||
const range = window.getSelection()!.getRangeAt(0);
|
||||
expect(range.startContainer).toBe(pane);
|
||||
expect(range.endContainer).toBe(pane);
|
||||
expect(range.startContainer).toBe(view.getByText("GET /data.json").firstChild);
|
||||
expect(range.startOffset).toBe(0);
|
||||
expect(range.endContainer).toBe(view.getByText("GET /data.json").firstChild);
|
||||
expect(range.endOffset).toBe("GET /data.json".length);
|
||||
expect(window.getSelection()!.toString()).not.toContain("Request history");
|
||||
});
|
||||
|
||||
@@ -133,3 +135,13 @@ it("leaves Select All in an editable field to the field", () => {
|
||||
expect(selectReadingPane(input)).toBe(false);
|
||||
expect(selectReadingPane(document.body)).toBe(false);
|
||||
});
|
||||
|
||||
it("ends Select All in the pane's last selectable text, not on its edge before the composer", () => {
|
||||
const view = fixture();
|
||||
expect(selectReadingPane(view.getByText("First message"))).toBe(true);
|
||||
const range = window.getSelection()!.getRangeAt(0);
|
||||
expect(range.startContainer).toBe(view.text("First message"));
|
||||
expect(range.endContainer).toBe(view.text("Last message"));
|
||||
expect(range.endOffset).toBe("Last message".length);
|
||||
expect(window.getSelection()!.toString()).not.toContain("Reply to the agent");
|
||||
});
|
||||
@@ -120,9 +120,10 @@ export function selectReadingPane(focused: Element | null): boolean {
|
||||
const pane = focused.closest<HTMLElement>("[data-selection-scope]");
|
||||
const selection = pane?.ownerDocument.getSelection();
|
||||
if (!pane || !selection) return false;
|
||||
const range = pane.ownerDocument.createRange();
|
||||
range.selectNodeContents(pane);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
// From the first to the last text, not the pane's own boundaries, which WebKit paints into
|
||||
// the field after the pane (the composer).
|
||||
const start = textEdge(pane, "start");
|
||||
const end = textEdge(pane, "end");
|
||||
selection.setBaseAndExtent(start.node, start.offset, end.node, end.offset);
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user