Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 7beee38e29 fix(debugger): silence the unix-only runInTerminal pid on Windows
ProgramTerminal's pid and process_id are read only by the unix process-group
cleanup, so Windows clippy failed with dead-code warnings.
2026-11-15 04:11:20 +08:00
436 змінених файлів з 1171 додано та 11519 видалено

No files matched your search

+3 -3
Переглянути файл
@@ -98,10 +98,10 @@ jobs:
# Test fixtures write LF files; keep Git from converting them on checkout.
- run: git config --global core.autocrlf false
- run: cargo nextest run --workspace --no-fail-fast
# nextest ran export_bindings, which rewrites src/bindings.ts and the command snapshot.
- name: Bindings and command snapshot are up to date
# nextest ran export_bindings, which rewrites src/bindings.ts.
- name: Bindings are up to date
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts src-tauri/tests/commands.snap
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
Створено
-1
Переглянути файл
@@ -4232,7 +4232,6 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"png 0.18.1",
]
[[package]]
+37 -303
Переглянути файл
@@ -283,11 +283,6 @@ struct TaskFile {
/// The folder (in the task store) with copies of the `before` files, for rolling back.
#[serde(default, skip_serializing_if = "Option::is_none")]
saved: Option<String>,
/// In the project's own checkout, where the user and other tasks change files too: the
/// files that changed while one of this task's turns ran, the only ones its changes list.
/// `None` for a worktree task, and a task saved before these were recorded.
#[serde(default, skip_serializing_if = "Option::is_none")]
touched: Option<BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subagents: Vec<SavedSubagent>,
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
@@ -298,9 +293,6 @@ struct TaskFile {
title_renamed: bool,
}
/// The changed files of a checkout, stamped.
type Snapshot = BTreeMap<String, worktree::Stamp>;
struct TaskState {
task: AgentTask,
session_id: Option<String>,
@@ -308,18 +300,11 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
saved: Option<String>,
/// See `TaskFile::touched`.
touched: Option<Arc<BTreeSet<String>>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// A temporary side conversation: lives in memory only, never persisted.
ephemeral: bool,
/// The conversation a temporary side conversation starts with, sent to the agent with
/// its first real message; the task's own entries hold only the questions.
ephemeral_context: Option<String>,
/// Background tasks the agent reported as running (`run_in_background` commands and
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
/// waits for the last one. A (re)started process runs nothing yet.
@@ -439,14 +424,8 @@ impl AgentManager {
}
/// Routes events to `sink` (replacing the previous subscriber); returns every task.
/// Ephemeral tasks belonged to the previous page session: a fresh one starts without them.
pub fn subscribe(&self, sink: AgentSink) -> Vec<AgentTask> {
*self.shared.sink.write() = Some(sink);
let stale: Vec<Arc<Task>> = self.tasks.read().values().filter(|t| t.state.lock().ephemeral).cloned().collect();
for task in &stale {
self.drop_in_memory(task);
self.shared.emit(AgentEvent::Removed { id: task.id.clone() });
}
let mut tasks: Vec<AgentTask> = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect();
tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
tasks
@@ -676,18 +655,19 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let handle = self.register(TaskFile {
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
touched: Some(BTreeSet::new()),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
});
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
@@ -725,8 +705,27 @@ impl AgentManager {
(root, None, base, commit, before)
}
};
// The session's settings: what the agent offered last time, with the choices applied.
let config = self.merged_config(&spec.id, &req.config);
// Until the session reports its own, show what the agent offered last time with the choices applied.
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
{
config.extend(levels.into_iter().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
// Copies of the files the user had changed already, for rolling back the task's changes to them.
@@ -767,128 +766,26 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let handle = self.register(TaskFile {
let file = TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved,
touched: (req.isolation == Isolation::Local).then(BTreeSet::new),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
});
};
let handle = Task::restore(file, self.shared.clone());
handle.state.lock().busy = true;
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
self.shared.runtime.spawn(async move { handle.run(prompt).await });
Ok(task)
}
/// Builds the in-memory handle of a fresh task, registers it and announces it. The
/// caller decides what else its life starts with (a persisted file, a session, a run).
fn register(&self, file: TaskFile) -> Arc<Task> {
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(handle.id.clone(), handle.clone());
handle.emit_task();
handle
}
/// The session's settings: what the agent offered last time, with the request's
/// choices — and the chosen model's thought levels — applied.
fn merged_config(&self, provider: &str, req: &[AgentConfigChoice]) -> Vec<AgentConfigOption> {
let catalog = self.shared.paths.catalogs.cached(&self.shared, provider);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, provider);
for choice in req {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
{
config.extend(levels.into_iter().map(|mut o| {
if let Some(choice) = req.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) {
o.current = Some(choice.value.clone());
}
o
}));
}
config
}
/// Creates a temporary side conversation of `source`: an in-memory task that is never
/// persisted, whose session starts with its first real message (`prompt` only names
/// the title). `source`'s conversation rides along as the context of that message.
/// The task is read-only: its settings are pushed to a mode that asks before anything,
/// and requests that would change the project are denied regardless. Fails for an
/// agent that offers no way to ask.
pub async fn create_ephemeral(&self, req: NewAgentTask, source: &str) -> IdeResult<AgentTask> {
let spec = self
.shared
.spec(&req.provider)
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the topic first"))?;
let title = title_of(&prompt);
let mut config = self.merged_config(&spec.id, &req.config);
config::ask_for_approvals(&mut config);
if config::bypass_approvals(&config) {
return Err(IdeError::invalid(
"this agent has no mode that asks before changing things, so it can't run a side conversation",
));
}
let read_only = !config::bypass_approvals(&config);
let context = context_transcript(&self.task(source)?.state.lock().conversation.main.entries);
let root = self.shared.paths.project_root.clone();
let before = blocking(move || Ok(worktree::snapshot(&root).unwrap_or_default())).await?;
let now = now_ms();
let mut capabilities = self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default();
capabilities.read_only = read_only;
let task = AgentTask {
project_root: Some(IpcPath(self.shared.paths.project_root.clone())),
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(self.shared.paths.project_root.clone()),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities,
resets_ms: None,
retry: None,
background: Box::default(),
config,
};
let handle = self.register(TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved: None,
touched: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
});
{
let mut s = handle.state.lock();
s.ephemeral = true;
s.ephemeral_context = Some(context);
}
Ok(task)
}
/// Sends a message. While the agent works it is queued, or with `now` the current turn
/// is cancelled and the message goes next.
pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
@@ -1109,7 +1006,6 @@ impl AgentManager {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
@@ -1309,15 +1205,6 @@ impl AgentManager {
Ok(())
}
/// Removes a task from the manager's table and stops its agent. The disk (its file,
/// its worktree) is the caller's business.
fn drop_in_memory(&self, task: &Arc<Task>) {
task.kill();
self.tasks.write().remove(&task.id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{}.json", task.id)));
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
@@ -1401,8 +1288,8 @@ impl AgentManager {
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let (before, touched) = task.attribution();
blocking(move || Ok(worktree::made_by_task(at.changes(&before)?, touched.as_deref()))).await
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
@@ -1410,13 +1297,8 @@ impl AgentManager {
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
let task = self.task(id)?;
let at = task.base();
let (before, touched) = task.attribution();
blocking(move || {
let mut grouped = at.grouped_changes(&before)?;
grouped.uncommitted = worktree::made_by_task(grouped.uncommitted, touched.as_deref());
Ok(grouped)
})
.await
let before = task.state.lock().before.clone();
blocking(move || at.grouped_changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
@@ -1442,16 +1324,15 @@ impl AgentManager {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (before, saved, touched) = {
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)), s.touched.clone())
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let (changes, rev) = match view {
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
};
let changes = worktree::made_by_task(changes, touched.as_deref());
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
@@ -1542,12 +1423,9 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
saved: file.saved,
touched: file.touched.map(Arc::new),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
ephemeral: false,
ephemeral_context: None,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
@@ -1569,41 +1447,6 @@ impl Task {
})
}
/// The files already changed when the task started, and the files its turns changed.
fn attribution(&self) -> (Arc<Snapshot>, Option<Arc<BTreeSet<String>>>) {
let s = self.state.lock();
(s.before.clone(), s.touched.clone())
}
/// The changed files of the checkout as a turn starts, when the task records what its turns change.
async fn checkout_before_turn(&self) -> Option<Snapshot> {
let cwd = {
let s = self.state.lock();
s.touched.as_ref()?;
s.task.cwd.0.clone()
};
blocking(move || worktree::snapshot(&cwd)).await.ok()
}
/// Records the files that changed in the checkout since `start`, as the turn ends, as the task's.
async fn record_turn_changes(&self, start: Option<Snapshot>) {
let Some(start) = start else { return };
let cwd = self.state.lock().task.cwd.0.clone();
let Ok(end) = blocking(move || worktree::snapshot(&cwd)).await else { return };
let changed = worktree::changed_between(&start, &end);
if changed.is_empty() {
return;
}
{
let mut s = self.state.lock();
let Some(touched) = &s.touched else { return };
let mut touched = (**touched).clone();
touched.extend(changed);
s.touched = Some(Arc::new(touched));
}
self.persist();
}
fn base(&self) -> worktree::TaskBase {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
@@ -1684,9 +1527,6 @@ impl Task {
if *deleted {
return;
}
if self.state.lock().ephemeral {
return;
}
let bytes = {
let s = self.state.lock();
serde_json::to_vec(&TaskFile {
@@ -1696,7 +1536,6 @@ impl Task {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
@@ -1748,11 +1587,9 @@ impl Task {
/// the user stops one. The caller set `busy`.
async fn run(self: Arc<Self>, mut prompt: AgentPrompt) {
loop {
let start = self.checkout_before_turn().await;
let result = self.turn(&prompt).await;
self.cancel_pending.store(false, Ordering::Release);
tokio::time::sleep(TURN_GRACE).await;
self.record_turn_changes(start).await;
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
self.mark_dirty(changed);
let send_now = self.send_now.swap(false, Ordering::AcqRel);
@@ -1920,20 +1757,7 @@ impl Task {
if !prompt.images.is_empty() && !live.capabilities.images {
return Err(Failure::from("This agent can't read images.".to_owned()));
}
// A temporary side conversation's context rides with its first real message; the
// conversation itself records only the question (and whatever comes after).
let prompt = {
let context = self.state.lock().ephemeral_context.take();
match context {
Some(context) => {
let mut owned = prompt.clone();
owned.text = format!("{context}\n\n---\n\n{}", owned.text);
owned
}
None => prompt.clone(),
}
};
let content = self.content(&live, &prompt).await;
let content = self.content(&live, prompt).await;
let result: Result<Value, RpcError> =
live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await;
match result {
@@ -2387,29 +2211,6 @@ impl Task {
.collect()
})
.unwrap_or_default();
// A side conversation is read-only: deny anything that would change the project
// (its task is deleted when its pane closes, leaving nothing to review or revert).
if self.state.lock().ephemeral
&& params
.pointer("/toolCall/kind")
.or_else(|| params.pointer("/subject/toolCall/kind"))
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "edit" | "delete" | "move" | "execute"))
{
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: None,
options,
questions: Vec::new(),
chosen: Some("Denied: side conversations are read-only".to_owned()),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
@@ -2607,72 +2408,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
/// The character budget of a side conversation's context: the newest entries win, the
/// oldest are dropped first. Leaves room for the question and the agent's answer.
const CONTEXT_BUDGET: usize = 100_000;
/// A long tool output trimmed to its first and last few lines, with the elided middle noted.
fn trimmed_output(output: &str) -> String {
const KEPT: usize = 4;
let lines: Vec<&str> = output.lines().collect();
if lines.len() <= KEPT * 2 + 1 {
return output.to_owned();
}
let omitted = lines.len() - KEPT * 2;
let mut out = lines[..KEPT].join("\n");
out.push_str(&format!("\n… {omitted} lines omitted …\n"));
out.push_str(&lines[lines.len() - KEPT..].join("\n"));
out
}
fn context_entry(e: &AgentEntry) -> Option<String> {
match e {
AgentEntry::User { text, .. } => Some(format!("User: {text}")),
AgentEntry::Agent { text, .. } if !text.trim().is_empty() => Some(format!("Agent: {text}")),
AgentEntry::Thought { text, .. } if !text.trim().is_empty() => Some(format!("Thought: {text}")),
AgentEntry::Tool { title, output, .. } => match output.as_deref() {
Some(output) if !output.trim().is_empty() => Some(format!("Tool: {title}\n{}", trimmed_output(output))),
_ => Some(format!("Tool: {title}")),
},
AgentEntry::Error { message, .. } => Some(format!("Error: {message}")),
_ => None,
}
}
/// The conversation as the context a temporary side conversation starts with: what was
/// said, what was done (tool calls with trimmed output), errors and the agent's reasoning,
/// oldest first, within [`CONTEXT_BUDGET`].
fn context_transcript(entries: &[AgentEntry]) -> String {
// Newest wins: walk back until the budget is spent, dropping the oldest entries first.
let mut lines: Vec<String> = Vec::new();
let mut used = 0usize;
for e in entries.iter().rev() {
let Some(line) = context_entry(e) else { continue };
if used + line.len() > CONTEXT_BUDGET {
if lines.is_empty() {
// Even the newest entry alone exceeds the budget: keep its head.
lines.push(line.chars().take(CONTEXT_BUDGET).collect());
}
break;
}
used += line.len();
lines.push(line);
}
lines.reverse();
let dropped = entries.iter().rev().filter_map(context_entry).count() - lines.len();
let mut context = String::from(
"You are answering a side question in a temporary conversation. The conversation so far is below, \
for context only; answer the new question after the marker, and do not continue the main task \
unless asked.\n\n<conversation>\n",
);
if dropped > 0 {
context.push_str(&format!("(The {dropped} oldest entries are omitted.)\n\n"));
}
context.push_str(&lines.join("\n\n"));
context.push_str("\n</conversation>");
context
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
@@ -2873,7 +2608,6 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
read_only: false,
};
(capabilities, init.prompt("embeddedContext"))
}
-3
Переглянути файл
@@ -36,9 +36,6 @@ pub struct AgentCapabilities {
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
/// The task runs read-only: it asks before anything, and requests that would change
/// the project are denied (a temporary side conversation).
pub read_only: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+3 -203
Переглянути файл
@@ -2211,7 +2211,6 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
@@ -2843,14 +2842,12 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
// What the agent did, while its turn ran.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// What the agent did.
write("mine.txt", "my edit\nand the agent's\n");
write("other.txt", "agent\n");
write("new.txt", "agent\n");
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// The user's (or another task's) work between turns is not the task's.
write("later.txt", "mine\n");
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -2866,7 +2863,6 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert_eq!(read("later.txt"), "mine\n", "not the task's to roll back");
assert!(changed(&mgr, &task.id).await.is_empty());
mgr.delete(&task.id, false).await.unwrap();
@@ -2951,17 +2947,10 @@ impl Checkout {
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
(mgr, task)
}
}
/// Counts every change in the checkout as the task's, as for a task saved before tasks
/// recorded what their turns changed: for tests that change files outside a turn.
fn count_every_change(mgr: &AgentManager, id: &str) {
mgr.task(id).unwrap().state.lock().touched = None;
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -3238,192 +3227,3 @@ async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_co
"{entries:?}"
);
}
/// A temporary side conversation is created ready, without starting its session and
/// without writing anything to the store; a fresh subscriber (a reloaded page) sweeps
/// it, and deleting it just drops it from memory.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_stay_in_memory_only() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("hello"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let calls_before = agent.calls.lock().len();
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
// Registering it starts nothing: the session waits for its first real message.
assert_eq!(agent.calls.lock().len(), calls_before);
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// Its first message starts the session like any task's, and still saves nothing: the
// conversation records the question, not the context that rode along to the agent.
// The fake agent's turn asks to change something, which a side conversation denies by
// itself, so the turn ends without waiting for the user.
mgr.prompt(&task.id, text("Why?"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let question = entries.iter().find_map(|e| match e {
AgentEntry::User { text, .. } => Some(text.clone()),
_ => None,
});
assert_eq!(question.as_deref(), Some("Why?"));
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c.contains("read-only"))),
"its `cargo test` request was denied: {entries:?}"
);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// A fresh subscriber (a reloaded page) sweeps it: it never reaches the task list.
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let tasks = mgr.subscribe(Arc::new(move |e| sink_events.lock().push(e)));
assert!(!tasks.iter().any(|t| t.id == task.id));
assert!(mgr.task(&task.id).is_err());
assert!(events.lock().iter().any(|e| matches!(e, AgentEvent::Removed { id } if *id == task.id)));
mgr.shutdown();
}
/// A side conversation's context stays within its budget: long tool outputs are cut to
/// their head and tail, and entries that no longer fit are dropped oldest first.
#[test]
fn side_conversation_context_is_bounded() {
let long_output = (0..400).map(|i| format!("line {i}")).collect::<Vec<_>>().join(
"
",
);
// Each reply alone is well inside the budget; together they are past it.
let reply = |n: usize| AgentEntry::Agent { text: format!("reply {n} {}", "x".repeat(30_000)) };
let entries = vec![
AgentEntry::User {
text: "oldest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
reply(1),
reply(2),
reply(3),
reply(4),
AgentEntry::Tool {
id: "t1".into(),
title: "grep".into(),
kind: "search".into(),
status: AgentToolStatus::Completed,
locations: vec![],
output: Some(long_output),
diffs: vec![],
},
AgentEntry::User {
text: "newest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
];
let transcript = context_transcript(&entries);
assert!(transcript.contains("newest question"), "the newest entry is always kept");
assert!(transcript.contains("lines omitted"), "a long tool output is trimmed");
assert!(transcript.contains("line 0") && transcript.contains("line 399"), "its head and tail stay");
assert!(!transcript.contains("oldest question"), "entries past the budget are dropped oldest first");
assert!(transcript.contains("oldest entries are omitted"));
}
/// An ephemeral task denies a permission request that would change the project.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_deny_write_permissions() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Something with a conversation to reuse"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let pending = mgr
.entries(&source.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
_ => None,
})
.unwrap_or_else(|| panic!("no pending permission request"));
mgr.respond(&source.id, pending, Some("no".into())).unwrap();
wait_for(&mgr, &source.id, AgentStatus::Ready).await;
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.read_only);
let answer = mgr
.task(&task.id)
.unwrap()
.ask_permission(&json!({
"sessionId": "c1",
"toolCall": {"toolCallId": "c1-t2", "title": "Edit main.rs", "kind": "edit"},
"options": [
{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}
]
}))
.pointer("/outcome/optionId")
.and_then(Value::as_str)
.unwrap_or("none")
.to_owned();
assert_eq!(answer, "no", "the write request is denied");
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(chosen), .. } if chosen.contains("read-only"))),
"{entries:?}"
);
mgr.shutdown();
}
+1 -36
Переглянути файл
@@ -3,7 +3,7 @@
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -548,25 +548,6 @@ pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
}
/// The files whose state changed between two snapshots of a checkout: edited, new, deleted,
/// or back to as committed.
pub(crate) fn changed_between(start: &BTreeMap<String, Stamp>, end: &BTreeMap<String, Stamp>) -> Vec<String> {
let mut changed: Vec<String> =
end.iter().filter(|(path, stamp)| start.get(*path) != Some(*stamp)).map(|(path, _)| path.clone()).collect();
changed.extend(start.keys().filter(|path| !end.contains_key(*path)).cloned());
changed
}
/// The changes among `changes` that the task made, when it records the files its turns
/// changed (`None`: every change counts).
pub(crate) fn made_by_task(changes: Vec<AgentChange>, touched: Option<&BTreeSet<String>>) -> Vec<AgentChange> {
let Some(touched) = touched else { return changes };
changes
.into_iter()
.filter(|c| touched.contains(&c.rel_path) || c.old_path.as_ref().is_some_and(|old| touched.contains(old)))
.collect()
}
/// Drops the changes that were there before the task and haven't been touched since.
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
@@ -1203,20 +1184,4 @@ mod tests {
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
#[test]
fn a_turn_changed_the_files_whose_state_moved() {
let start: BTreeMap<String, Stamp> =
[("edited", Some((1, 1))), ("same", Some((2, 2))), ("reverted", Some((3, 3)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let end: BTreeMap<String, Stamp> = [("edited", Some((1, 9))), ("same", Some((2, 2))), ("new", Some((4, 4)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let mut changed = changed_between(&start, &end);
changed.sort();
assert_eq!(changed, ["edited", "new", "reverted"]);
}
}
+3 -9
Переглянути файл
@@ -1,6 +1,6 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -9,14 +9,8 @@ license.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
[target.'cfg(windows)'.dependencies]
png = "0.18"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[dev-dependencies]
png = "0.18"
+3 -222
Переглянути файл
@@ -18,10 +18,6 @@
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
//!
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
use std::path::PathBuf;
@@ -38,19 +34,6 @@ pub fn text() -> Option<String> {
imp::text()
}
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
/// thread on macOS.
#[cfg(any(windows, target_os = "macos"))]
pub fn png() -> Option<Vec<u8>> {
imp::png()
}
/// Whether the clipboard holds an image, without reading it. Call on the main
/// thread on macOS and Linux.
pub fn has_image() -> bool {
imp::has_image()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -151,62 +134,9 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
out
}
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
#[cfg(any(windows, test))]
const BI_RGB: u32 = 0;
#[cfg(any(windows, test))]
const BI_BITFIELDS: u32 = 3;
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
/// all zero (most writers leave it unset) is opaque.
#[cfg(any(windows, test))]
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
let header = u32_at(0)? as usize;
let width = u32_at(4)? as i32;
let height = u32_at(8)? as i32;
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
let compression = u32_at(16)?;
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
return None;
}
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
return None;
}
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
// headers hold them inside. Masks other than BGRA's are not supported.
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
let colors = u32_at(32)? as usize;
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
let stride = (width * bits as usize).div_ceil(32) * 4;
let start = header + masks + colors * 4;
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
let step = bits as usize / 8;
let mut rgba = Vec::with_capacity(width * rows * 4);
for row in 0..rows {
// A positive height stores the bottom row first.
let source = if height > 0 { rows - 1 - row } else { row };
let line = &pixels[source * stride..source * stride + width * step];
for px in line.chunks_exact(step) {
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
}
}
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
}
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
Some(out)
}
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, dib_to_png, encode, names_text};
use super::{HEADER, decode, encode, names_text};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -234,59 +164,6 @@ mod imp {
fn GetClipboardData(format: u32) -> Handle;
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
fn IsClipboardFormatAvailable(format: u32) -> i32;
fn RegisterClipboardFormatW(name: *const u16) -> u32;
}
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
/// bitmap on the clipboard reads as one.
const CF_DIB: u32 = 8;
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
/// keeps transparency, so it is read first.
fn png_format() -> u32 {
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
}
pub fn has_image() -> bool {
let png = png_format();
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
}
/// A copy of the clipboard's `format` block. Call with the clipboard open.
fn read_block(format: u32) -> Option<Vec<u8>> {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let memory = GetClipboardData(format);
if memory.is_null() {
return None;
}
let data = GlobalLock(memory);
if data.is_null() {
return None;
}
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
GlobalUnlock(memory);
Some(bytes)
}
}
pub fn png() -> Option<Vec<u8>> {
if !open() {
return None;
}
let png = png_format();
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
read_block(png)
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
} else {
None
};
unsafe { CloseClipboard() };
image
}
#[link(name = "kernel32")]
@@ -450,8 +327,8 @@ mod imp {
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
@@ -503,33 +380,6 @@ mod imp {
})
}
pub fn has_image() -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
})
}
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
pub fn png() -> Option<Vec<u8>> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
return Some(png.to_vec());
}
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
// SAFETY: an empty property dictionary is valid for every file type.
let png =
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
Some(png.to_vec())
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -598,17 +448,6 @@ mod imp {
false
}
pub fn has_image() -> bool {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
@@ -682,62 +521,4 @@ mod tests {
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
}
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&40u32.to_le_bytes());
out.extend_from_slice(&width.to_le_bytes());
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&bits.to_le_bytes());
out.extend_from_slice(&compression.to_le_bytes());
out.extend_from_slice(&[0u8; 20]);
if compression == super::BI_BITFIELDS {
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
out.extend_from_slice(&mask.to_le_bytes());
}
}
for row in rows {
out.extend_from_slice(row);
}
out
}
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
#[test]
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
// Rows are padded to 4 bytes; the first stored row is the bottom one.
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
let (w, h, rgba) = decode_png(&png);
assert_eq!((w, h), (2, 2));
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
}
#[test]
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
}
#[test]
fn unsupported_or_truncated_bitmaps_are_not_images() {
let row: &[u8] = &[1, 2, 3, 4];
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&[0; 10]).is_none());
}
}
-24
Переглянути файл
@@ -141,30 +141,6 @@ pub fn process_tree(pid: i32) -> Vec<i32> {
tree
}
/// Every process `pid` started, directly or not, parents before their children.
pub fn descendants(pid: u32) -> Vec<u32> {
#[cfg(unix)]
{
let Ok(root) = i32::try_from(pid) else { return Vec::new() };
process_tree(root).into_iter().skip(1).filter_map(|p| u32::try_from(p).ok()).collect()
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
windows_tree::descendants(pid)
.iter()
// SAFETY: a live process handle opened with query access.
.map(|h| unsafe { windows_sys::Win32::System::Threading::GetProcessId(h.as_raw_handle()) })
.filter(|&p| p != 0)
.collect()
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
Vec::new()
}
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
-7
Переглянути файл
@@ -86,8 +86,6 @@ pub struct AgentSettings {
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
/// Conversations and the composer expand to the full width of the pane instead of staying in a centred column.
pub fit_window_width: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
@@ -129,7 +127,6 @@ impl Default for AgentSettings {
show_cost: false,
fold_turns: true,
smooth_text: true,
fit_window_width: false,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
@@ -1718,15 +1715,11 @@ mod tests {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!old.agents.fit_window_width);
assert!(!Settings::default().agents.show_cost);
assert!(!Settings::default().agents.fit_window_width);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
settings.agents.fit_window_width = true;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
assert_eq!(json["agents"]["fitWindowWidth"], true);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
-3
Переглянути файл
@@ -327,8 +327,6 @@ pub struct RunSettings {
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
pub show_on_start: bool,
/// New Run/Debug tabs start pinned: a later run doesn't take their place once finished.
pub pin_tabs: bool,
/// Grace period between interrupt and kill when stopping a process.
pub termination_timeout_ms: u32,
}
@@ -340,7 +338,6 @@ impl Default for RunSettings {
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
pin_tabs: false,
termination_timeout_ms: 3000,
}
}
-40
Переглянути файл
@@ -405,24 +405,6 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
Ok(out)
}
/// The Attach to Process candidates among the processes `root` started, parents first.
/// Blocking, like [`attachable_processes`].
pub fn attachable_descendants(root: u32) -> IdeResult<Vec<AttachableProcess>> {
let order = ide_core::process::descendants(root);
if order.is_empty() {
return Ok(Vec::new());
}
let cwd = std::env::temp_dir();
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
let mut out: Vec<AttachableProcess> = list_processes()?
.iter()
.filter(|p| order.contains(&p.pid))
.filter_map(|p| classify(p, is_dotnet, is_go_binary, native))
.collect();
out.sort_by_key(|p| order.iter().position(|&pid| pid == p.pid));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -436,28 +418,6 @@ mod tests {
.map(|p| (p.kind, p.port))
}
#[cfg(unix)]
#[test]
fn a_runs_candidates_are_the_processes_it_started() {
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30; true"]).spawn().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let found = loop {
let found = attachable_descendants(child.id()).unwrap();
if !found.is_empty() || std::time::Instant::now() > deadline {
break found;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
for p in &found {
let _ = std::process::Command::new("kill").arg(p.pid.to_string()).status();
}
child.kill().unwrap();
child.wait().unwrap();
assert!(found.iter().all(|p| p.pid != child.id()), "{found:?}");
assert!(found.iter().any(|p| p.name == "sleep" && p.kind == AttachKind::Native), "{found:?}");
assert!(attachable_descendants(u32::MAX - 1).unwrap().is_empty());
}
#[test]
fn parses_jdwp_agents() {
let a = "java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar";
+1 -1
Переглянути файл
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.9.0";
pub const ENGINE_VERSION: &str = "1.8.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
-30
Переглянути файл
@@ -325,20 +325,6 @@ pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
if cfg!(target_os = "windows") {
relative_tool_shims(&local.join("tools"));
}
}
/// Wrappers written before they named their tool relative to themselves hold its absolute
/// path, which cmd.exe can't find through a non-ASCII user directory (#3338).
fn relative_tool_shims(tools: &Path) {
let Ok(entries) = fs::read_dir(tools) else { return };
for dir in entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
if let Err(error) = relocate_shims(&dir, &dir) {
tracing::warn!(%error, ?dir, "cannot make the tool's wrappers relative");
}
}
}
fn migrate_entries(data: &Path, local: &Path) {
@@ -665,22 +651,6 @@ mod tests {
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn installed_tool_wrappers_become_relative() {
let tmp = tempfile::tempdir().unwrap();
let tools = tmp.path().join("tools");
let tool = tools.join("taplo");
fs::create_dir_all(tool.join("bin")).unwrap();
let shim = tool.join("bin/taplo.cmd");
fs::write(&shim, format!("@echo off\r\n\"{}\\taplo.exe\" %*\r\n", tool.display())).unwrap();
// A wrapper the user re-saved in another encoding is left alone.
fs::create_dir_all(tools.join("other/bin")).unwrap();
fs::write(tools.join("other/bin/x.cmd"), b"@echo off\r\n\"C:\\\xd5\xc5\" %*\r\n").unwrap();
relative_tool_shims(&tools);
assert_eq!(fs::read_to_string(&shim).unwrap(), "@echo off\r\n\"%~dp0..\\taplo.exe\" %*\r\n");
relative_tool_shims(&tmp.path().join("missing"));
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+12 -38
Переглянути файл
@@ -251,12 +251,18 @@ impl KotlinBridge {
}
}
other => {
let (text, notify) = match other {
Ok(Ok(outcome)) => failed_build(&outcome),
Ok(Err(error)) => (error.message, true),
Err(error) => (error.to_string(), true),
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
};
self.report(text, notify, &mut diagnostics, &mut last_failure);
self.failure(text, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
@@ -270,12 +276,6 @@ impl KotlinBridge {
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
self.report(text, true, paths, previous);
}
/// Publishes a failed build in Problems (on `pom.xml`) and, when `notify`, as a
/// notification once per distinct message.
fn report(&self, text: String, notify: bool, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
@@ -295,7 +295,7 @@ impl KotlinBridge {
deprecated: false,
}],
});
if notify && previous != &text {
if previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
@@ -306,15 +306,6 @@ impl KotlinBridge {
}
}
/// The error text of a failed bridge build, and whether to notify about it. Compiler errors
/// in the user's own Kotlin already show in its editor and stay in Problems; only a build
/// that failed without one (Maven itself, a missing plugin) is worth a notification.
fn failed_build(outcome: &Outcome) -> (String, bool) {
let errors: Vec<_> = outcome.messages.iter().filter(|m| m.severity == Severity::Error).collect();
let text = errors.iter().map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
(text, !errors.iter().any(|m| m.path.is_some()))
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
@@ -392,23 +383,6 @@ mod tests {
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn source_errors_stay_out_of_notifications() {
let message = |text: &str, path: Option<&str>| ide_build::compile::BuildMessage {
severity: Severity::Error,
text: text.into(),
path: path.map(|p| PathBuf::from(p).into()),
range: None,
code: None,
};
let outcome = |messages| Outcome { projects: Vec::new(), messages, success: false };
let (text, notify) = failed_build(&outcome(vec![message("No value passed", Some("/p/src/A.kt"))]));
assert_eq!((text.as_str(), notify), ("No value passed", false));
let (text, notify) = failed_build(&outcome(vec![message("Plugin not found", None)]));
assert_eq!((text.as_str(), notify), ("Plugin not found", true));
assert!(failed_build(&outcome(Vec::new())).1);
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
+6 -29
Переглянути файл
@@ -1,10 +1,6 @@
use super::page::{OFFSET, Page};
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Map, Value, json};
/// Problems `get_project_problems` lists by default.
const PROBLEMS_LIMIT: usize = 100;
use serde_json::{Value, json};
pub fn tools() -> Vec<Tool> {
vec![
@@ -41,10 +37,7 @@ Lines and UTF-16 columns are 1-based.",
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
)
.param(Param::optional("errorsOnly", ParamType::Boolean, "Only errors (default) or warnings too"))
.param(Param::optional("limit", ParamType::Integer, "Problems to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET)),
),
]
}
@@ -97,25 +90,9 @@ async fn file_problems(call: Call) -> IdeResult<Output> {
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let page = Page::of(&call, PROBLEMS_LIMIT)?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let problems: Vec<(String, Value)> = files
.into_iter()
.flat_map(|f| {
let path = call.display(&f.path.0);
let wanted = f.items.into_iter().filter(|d| !errors_only || d.severity == Severity::Error);
wanted.map(move |d| (path.clone(), problem(d, "")))
})
.collect();
let mut out = Map::new();
let mut files: Vec<Value> = Vec::new();
for (path, problem) in page.take(problems, &mut out) {
match files.last_mut() {
Some(file) if file["filePath"] == path.as_str() => file["problems"].as_array_mut().unwrap().push(problem),
_ => files.push(json!({ "filePath": path, "problems": [problem] })),
}
}
out.insert("files".into(), files.into());
Output::json(Value::Object(out))
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
}
+6 -100
Переглянути файл
@@ -2,7 +2,6 @@
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
@@ -18,8 +17,6 @@ const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
/// Entries `list_directory_tree` lists by default.
const TREE_LIMIT: usize = 100;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
@@ -152,12 +149,6 @@ struct DirectoryTreeInfo {
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
/// Entries under the directory within `maxDepth`.
total: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
}
fn list_directory_tree() -> Tool {
@@ -165,12 +156,10 @@ fn list_directory_tree() -> Tool {
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.
A page that starts inside a directory repeats the lines of the directories above it.",
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let page = Page::of(&call, TREE_LIMIT)?;
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
@@ -181,16 +170,8 @@ A page that starts inside a directory repeats the lines of the directories above
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline,
cancel: cancel.clone(),
page,
seen: 0,
above: Vec::new(),
};
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let tree = call
.host()
.blocking(move || {
@@ -206,16 +187,11 @@ A page that starts inside a directory repeats the lines of the directories above
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
total: tree.seen,
more: tree.seen > page.end(),
next_offset: (tree.seen > page.end()).then_some(page.end()),
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
@@ -224,28 +200,19 @@ A page that starts inside a directory repeats the lines of the directories above
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
"total": { "type": "integer" },
"more": { "type": "boolean" },
"nextOffset": { "type": "integer" },
},
"required": ["traversedDirectory", "tree", "errors", "total"],
"required": ["traversedDirectory", "tree", "errors"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`; entries outside the
/// page are counted, not listed.
/// ignored ones too) by lower-cased name, directories marked with `/`.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
page: Page,
/// Entries met so far.
seen: usize,
/// The lines of the directories above the entry, and whether this page shows them yet.
above: Vec<(String, bool)>,
}
impl TreeRender {
@@ -268,13 +235,7 @@ impl TreeRender {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
let line = format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" });
let shown = if is_root {
self.out.push_str(&line);
true
} else {
self.entry(&line)
};
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
if !is_dir {
return;
}
@@ -289,32 +250,9 @@ impl TreeRender {
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
if !is_root {
self.above.push((line, shown));
}
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
if !is_root {
self.above.pop();
}
}
/// Counts an entry and lists it when on the page, after the directories above it.
fn entry(&mut self, line: &str) -> bool {
let n = self.seen;
self.seen += 1;
if n < self.page.offset || n >= self.page.end() {
return false;
}
for (above, shown) in &mut self.above {
if !*shown {
self.out.push_str(above);
*shown = true;
}
}
self.out.push_str(line);
true
}
}
@@ -364,36 +302,4 @@ mod tests {
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
#[test]
fn a_tree_page_repeats_the_directories_above_it() {
let tmp = tempfile::tempdir().unwrap();
for file in ["a/x.txt", "a/y.txt", "b.txt"] {
let path = tmp.path().join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let render = |offset, limit| {
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline: Instant::now() + Duration::from_secs(10),
cancel: CancellationToken::new(),
page: Page { offset, limit },
seen: 0,
above: Vec::new(),
};
tree.render(tmp.path(), "", true, 5, true);
let root = format!("{}/\n", tmp.path().display());
(tree.out.strip_prefix(&root).unwrap().to_owned(), tree.seen)
};
assert_eq!(render(0, 2), (" ├── a/\n │ ├── x.txt\n".to_owned(), 4));
assert_eq!(
render(2, 2).0,
" ├── a/\n │ └── y.txt\n └── b.txt\n",
"the page in `a/` repeats its line"
);
assert_eq!(render(0, 100).0, " ├── a/\n │ ├── x.txt\n │ └── y.txt\n └── b.txt\n");
}
}
-1
Переглянути файл
@@ -7,7 +7,6 @@ pub(crate) mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod page;
mod reports;
mod search;
mod symbols;
-71
Переглянути файл
@@ -1,71 +0,0 @@
//! `limit` and `offset` of the tools that list things: a few items by default, the rest page by page.
use super::tool::Call;
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
/// The most items one call returns.
pub const MAX_LIMIT: usize = 1000;
pub const OFFSET: &str = "Items to skip: `nextOffset` of the previous page";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Page {
pub offset: usize,
pub limit: usize,
}
impl Page {
/// The `limit` (`default` when absent, at most [`MAX_LIMIT`]) and `offset` (0) arguments.
pub fn of(call: &Call, default: usize) -> IdeResult<Self> {
let limit = match call.opt_i64("limit")? {
None => default,
Some(n) if n <= 0 => return Err(IdeError::invalid("limit must be > 0")),
Some(n) => (n as usize).min(MAX_LIMIT),
};
let offset = match call.opt_i64("offset")? {
None => 0,
Some(n) if n < 0 => return Err(IdeError::invalid("offset must be >= 0")),
Some(n) => n as usize,
};
Ok(Self { offset, limit })
}
/// Where the page ends: the items to find for it.
pub fn end(&self) -> usize {
self.offset.saturating_add(self.limit)
}
/// This page of all the `items`; `out` gets their `total`, and `more` and `nextOffset` when some follow.
pub fn take<T>(&self, items: Vec<T>, out: &mut Map<String, Value>) -> Vec<T> {
let total = items.len();
out.insert("total".into(), total.into());
self.mark(total > self.end(), out);
items.into_iter().skip(self.offset).take(self.limit).collect()
}
/// `more` and `nextOffset` when items follow the page.
pub fn mark(&self, more: bool, out: &mut Map<String, Value>) {
if more {
out.insert("more".into(), true.into());
out.insert("nextOffset".into(), self.end().into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_say_what_follows() {
let page = |offset, limit| Page { offset, limit };
let mut out = Map::new();
assert_eq!(page(0, 2).take(vec![1, 2, 3, 4, 5], &mut out), [1, 2]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5, "more": true, "nextOffset": 2 }));
let mut out = Map::new();
assert_eq!(page(4, 2).take(vec![1, 2, 3, 4, 5], &mut out), [5]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5 }), "the last page");
let mut out = Map::new();
assert!(page(9, 2).take(vec![1], &mut out).is_empty());
}
}
+4 -80
Переглянути файл
@@ -3,7 +3,6 @@
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
@@ -36,14 +35,6 @@ and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as
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";
/// `list_runs`: tabs returned by default, and characters of each command, enough to tell them apart.
const LIST_LIMIT: usize = 10;
const LIST_COMMAND_CHARS: usize = 80;
const LIST_STATES: [(&str, &[&str]); 3] = [
("running", &["starting", "running", "stopping"]),
("idle", &["succeeded", "failed", "crashed", "stoppedByAgent", "stoppedByUser", "startFailed"]),
("error", &["failed", "crashed", "startFailed"]),
];
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
@@ -292,64 +283,13 @@ for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`), newest first: name, state, the \
start of the command, exit code and times. `get_run_status` gives a tab's whole status.",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let page = Page::of(&call, LIST_LIMIT)?;
let wanted = call.opt_str("state")?.filter(|s| s != "all");
let states = match wanted.as_deref() {
None => None,
Some(s) => Some(match LIST_STATES.iter().find(|(group, _)| *group == s) {
Some((_, states)) => states.to_vec(),
None if LIST_STATES[1].1.contains(&s) || LIST_STATES[0].1.contains(&s) => vec![s],
None => {
return Err(IdeError::invalid(format!(
"unknown state `{s}`: all, running, idle, error, or one of the states of get_run_status"
)));
}
}),
};
let runs: Vec<Value> = call
.host()
.mcp_agent_runs
.list(call.root())
.iter()
.rev()
.map(|r| r.status())
.filter(|s| {
states.as_ref().is_none_or(|states| s["state"].as_str().is_some_and(|v| states.contains(&v)))
})
.map(|s| Value::Object(brief(s)))
.collect();
let mut out = Map::new();
let runs = page.take(runs, &mut out);
out.insert("runs".into(), runs.into());
Output::json(Value::Object(out))
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 }))
},
)
.param(Param::optional(
"state",
ParamType::String,
"all (default), running, idle (not running), error (failed, crashed, startFailed), or one state",
))
.param(Param::optional("limit", ParamType::Integer, "Tabs to return at most (default 10)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// A tab in `list_runs`: what tells it apart and how it went, the command cut short.
fn brief(mut status: Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for key in ["name", "state", "command", "exitCode", "crash", "error", "startedAt", "endedAt", "tabClosedAt"] {
if let Some(v) = status.remove(key) {
out.insert(key.into(), v);
}
}
if let Some(Value::String(command)) = out.get_mut("command")
&& command.chars().count() > LIST_COMMAND_CHARS
{
*command = format!("{}…", command.chars().take(LIST_COMMAND_CHARS).collect::<String>());
}
out
}
/// What a key does to a command on pipes.
@@ -467,22 +407,6 @@ pub fn tools() -> Vec<Tool> {
mod tests {
use super::*;
#[test]
fn listed_tabs_are_brief_and_their_commands_cut_short() {
let status = serde_json::json!({
"name": "_agent_build", "state": "failed", "command": "x".repeat(100), "exitCode": 1,
"workingDirectory": "", "outputLength": 9, "startedBy": "agent",
});
let Value::Object(status) = status else { unreachable!() };
let brief = Value::Object(brief(status));
assert_eq!(brief["command"].as_str().unwrap(), format!("{}…", "x".repeat(LIST_COMMAND_CHARS)));
assert_eq!(
(&brief["name"], &brief["state"], &brief["exitCode"]),
(&"_agent_build".into(), &"failed".into(), &1.into())
);
assert!(brief.get("outputLength").is_none() && brief.get("workingDirectory").is_none());
}
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
+3 -25
Переглянути файл
@@ -7,7 +7,6 @@ use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
@@ -20,8 +19,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// Configurations `get_run_configurations` lists by default.
const CONFIGURATIONS_LIMIT: usize = 100;
/// 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.
@@ -53,8 +50,7 @@ fn describe_config(call: &Call, c: &RunConfig) -> Value {
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
// A detected configuration's environment is the IDE's own (its tools on `PATH`).
if !c.env.is_empty() && !c.detected {
if !c.env.is_empty() {
v["environment"] = json!(c.env);
}
}
@@ -137,23 +133,8 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let page = Page::of(&call, CONFIGURATIONS_LIMIT)?;
let query = call.opt_str("query")?.map(|q| q.to_lowercase());
let list: Vec<Value> = configs
.iter()
.filter(|c| {
query.as_ref().is_none_or(|q| {
[c.name.as_str(), c.kind.as_str(), command_line(c).as_str()]
.iter()
.any(|s| s.to_lowercase().contains(q.as_str()))
})
})
.map(|c| describe_config(&call, c))
.collect();
let mut out = Map::new();
let list = page.take(list, &mut out);
out.insert("configurations".into(), list.into());
Output::json(Value::Object(out))
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
},
)
.write()
@@ -163,9 +144,6 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
.param(Param::optional("query", ParamType::String, "Only configurations whose name, type or command contains it"))
.param(Param::optional("limit", ParamType::Integer, "Configurations to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// What `execute_run_configuration` runs.
+16 -36
Переглянути файл
@@ -5,7 +5,6 @@
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
@@ -22,8 +21,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: usize = 100;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: usize = 50;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
@@ -38,8 +37,7 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Results to return at most (default 100)";
const SYMBOL_LIMIT_DESCRIPTION: &str = "Results to return at most (default 50)";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
@@ -69,7 +67,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -91,7 +88,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -117,8 +113,7 @@ If you don't find a suitable result, try again with include_external=true to sea
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, SYMBOL_LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -143,7 +138,6 @@ Paths are optional additional glob filters relative to the project root.",
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.output(result_schema())
}
@@ -197,9 +191,6 @@ struct SearchResult {
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
/// The `offset` of the next page, when `more`.
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
@@ -221,23 +212,16 @@ fn result_schema() -> Value {
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"nextOffset": int,
"partialResultReason": string,
},
})
}
/// The page of `call` and the results to find for it: up to its end.
fn page(call: &Call, default: usize) -> IdeResult<(Page, usize)> {
let page = Page::of(call, default)?;
Ok((page, page.end().min(MAX_RESULTS_UPPER_BOUND)))
}
/// Drops the results before the page, and the `nextOffset` when more follow. A search finds
/// [`MAX_RESULTS_UPPER_BOUND`] results at most, so no page starts past them.
fn skip<T>(page: Page, items: &mut Vec<T>, more: bool) -> Option<usize> {
items.drain(..page.offset.min(items.len()));
(more && page.end() < MAX_RESULTS_UPPER_BOUND).then_some(page.end())
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
@@ -537,7 +521,7 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
@@ -628,12 +612,11 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let next_offset = skip(page, &mut items, more);
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
@@ -738,7 +721,7 @@ async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatch
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
@@ -776,11 +759,9 @@ async fn file_search(call: Call) -> IdeResult<Output> {
Ok((found, more, walk.timed_out.get()))
})
.await?;
let mut items: Vec<SearchItem> =
paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let next_offset = skip(page, &mut items, more);
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
struct Walk {
@@ -854,7 +835,7 @@ impl Walk {
async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let (page, limit) = page(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
@@ -905,7 +886,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let next_offset = skip(page, &mut items, more);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
@@ -919,7 +899,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
-15
Переглянути файл
@@ -210,7 +210,6 @@ fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let page = super::page::Page::of(&call, REFERENCES_LIMIT)?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
@@ -245,28 +244,14 @@ pub fn find_references() -> Tool {
}
}
positions(&mut value);
// `classified`, when there, goes with `locations` one to one.
if let serde_json::Value::Object(map) = &mut value {
let mut list = |key: &str| match map.remove(key) { Some(serde_json::Value::Array(items)) => items, _ => Vec::new() };
let (locations, classified) = (list("locations"), list("classified"));
let classified = page.take(classified, &mut serde_json::Map::new());
let locations = page.take(locations, map);
map.insert("locations".into(), locations.into());
map.insert("classified".into(), classified.into());
}
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
.param(Param::optional("limit", ParamType::Integer, "References to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, super::page::OFFSET))
}
/// References `find_references` lists by default.
const REFERENCES_LIMIT: usize = 100;
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
+11 -15
Переглянути файл
@@ -2,13 +2,12 @@
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: usize = 100;
const STATUS_DEFAULT_LIMIT: i64 = 1000;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
@@ -255,7 +254,11 @@ By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let page = Page::of(&call, STATUS_DEFAULT_LIMIT)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
@@ -302,8 +305,7 @@ By default all Git repositories are returned.",
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.skip(page.offset)
.take(page.limit)
.take(limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
@@ -316,24 +318,19 @@ By default all Git repositories are returned.",
v
})
.collect();
let more = entries.len() > page.end();
let mut repository = json!({
repositories.push(json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": more,
"hasMoreEntries": entries.len() > listed.len(),
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
});
if more {
repository["nextOffset"] = page.end().into();
}
repositories.push(repository);
}));
}
Output::json(json!({ "repositories": repositories }))
},
@@ -345,8 +342,7 @@ By default all Git repositories are returned.",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return per repository at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
}
fn git_diff() -> Tool {
-12
Переглянути файл
@@ -261,18 +261,6 @@ pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -
host.project(window)?.agents.fork(&task, turns).await
}
/// Creates a temporary side conversation of `source`: lives in memory only, never
/// persisted, not listed.
pub async fn create_ephemeral(
host: &Host,
window: &str,
request: NewAgentTask,
source: String,
) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.create_ephemeral(request, &source).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
-22
Переглянути файл
@@ -21,28 +21,6 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
Ok(())
}
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
/// through [`save_as`], which turns it into a regular file document. `name`
/// re-references an already minted buffer instead of creating one.
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
}
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
/// start watching the saved file's folder.
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || {
let info = project.documents.save_as(doc, &path)?;
if let Some(dir) = path.parent().filter(|_| project.light) {
project.watch_light_dir(dir);
}
Ok(info)
})
.await
}
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
host.project(window)?.documents.edit(doc, base_version, changes)
+1 -4
Переглянути файл
@@ -50,10 +50,7 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload
&& !project.documents.is_untitled(doc)?
&& let Some(uri) = project.documents.virtual_uri(doc)?
{
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
+1 -10
Переглянути файл
@@ -3047,16 +3047,7 @@ impl PluginWorkspace for HostWorkspace {
&project.root,
&project.settings.effective(),
);
if config.debug_adapter.is_none()
&& let Some(language) = config.extras.params.get(super::run_detectors::DEBUG_LANGUAGE)
{
config.debug_adapter = project
.languages
.get(&ide_core::LanguageId::new(language.as_str()))
.and_then(|l| l.debugger)
.map(|d| d.adapter);
}
if config.debug_adapter.is_none() && !config.extras.params.contains_key(super::run_detectors::DEBUG_ATTACH) {
if config.debug_adapter.is_none() {
let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root);
config.debug_adapter =
config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| {
-155
Переглянути файл
@@ -415,67 +415,6 @@ fn validate_configurations(
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
validate_env(&c.env)?;
validate_debugger(c.debugger.as_ref())?;
}
Ok(())
}
/// Like `process` commands, a configuration only adds variables: one the IDE's own
/// environment has (`PATH`, …) would change what the approved program runs.
fn validate_env(env: &[(String, String)]) -> Result<(), HostError> {
if env.len() > 64 {
return Err(HostError::Invalid("at most 64 environment variables".into()));
}
let mut names = std::collections::BTreeSet::new();
for (name, value) in env {
if name.is_empty() || name.len() > 256 || name.contains(['=', '\0']) {
return Err(HostError::Invalid(format!("{name:?} is not an environment variable name")));
}
if value.len() > 4096 || value.contains('\0') {
return Err(HostError::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
}
let key = if cfg!(windows) { name.to_ascii_uppercase() } else { name.clone() };
if !names.insert(key) || inherited_env(name) {
return Err(HostError::Invalid(format!(
"environment variable {name:?} is repeated or already set; only new variables may be added"
)));
}
}
Ok(())
}
fn inherited_env(name: &str) -> bool {
std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) { inherited.to_string_lossy().eq_ignore_ascii_case(name) } else { inherited == name }
})
}
/// Attach to Process kinds a configuration may attach to.
pub(super) const ATTACH_KINDS: [&str; 5] = ["dotnet", "node", "python", "go", "native"];
fn validate_debugger(debugger: Option<&ide_plugin::PluginDebugger>) -> Result<(), HostError> {
match debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
if language.is_empty()
|| language.len() > 64
|| !language.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(HostError::Invalid(format!("{language:?} is not a language id")));
}
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
if !ATTACH_KINDS.contains(&kind.as_str()) {
return Err(HostError::Invalid(format!(
"attach-child kind {kind:?} is not one of {}",
ATTACH_KINDS.join(", ")
)));
}
if command_contains.as_ref().is_some_and(|t| t.is_empty() || t.len() > 256 || t.contains('\0')) {
return Err(HostError::Invalid("attach-child command-contains must be 1-256 bytes".into()));
}
}
None => {}
}
Ok(())
}
@@ -592,52 +531,6 @@ impl PluginHost {
}
}
impl PluginHost {
/// The process an `attach-child` configuration's debugger attaches to: the first Attach to
/// Process candidate of its kind that `run` (a run of this configuration) started, waited
/// for while the run lasts, up to a minute. Only the run's own descendants are candidates.
pub fn debug_attach_target(
&self,
host: &crate::Host,
client: &str,
config: &str,
run: ide_core::RunId,
) -> IdeResult<ide_dap::attach::AttachableProcess> {
use super::run_detectors::{DEBUG_ATTACH, DEBUG_ATTACH_CONTAINS};
let Some((plugin, id)) = config.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) else {
return Err(IdeError::invalid("not a plugin configuration"));
};
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
let kind = owned
.extras
.params
.get(DEBUG_ATTACH)
.filter(|k| ATTACH_KINDS.contains(&k.as_str()))
.ok_or_else(|| IdeError::invalid("this configuration does not attach to a process"))?
.clone();
let contains = owned.extras.params.get(DEBUG_ATTACH_CONTAINS).cloned();
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let info = crate::ops::run::list(host).into_iter().find(|r| r.id == run && r.config_id == owned.id);
let Some(pid) = info.as_ref().filter(|r| r.running).and_then(|r| r.pid) else {
return Err(IdeError::not_found("the run ended before a process to debug started"));
};
let found = ide_dap::attach::attachable_descendants(pid)?.into_iter().find(|p| {
serde_json::to_value(p.kind).ok().and_then(|v| v.as_str().map(|k| k == kind)) == Some(true)
&& contains.as_ref().is_none_or(|t| p.command_line.contains(t.as_str()))
});
if let Some(process) = found {
return Ok(process);
}
if Instant::now() >= deadline {
return Err(IdeError::not_found(format!("no {kind} process started by the run within a minute")));
}
std::thread::sleep(Duration::from_millis(500));
}
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
@@ -712,52 +605,4 @@ mod tests {
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
);
}
fn launch(
env: Vec<(&str, &str)>,
debugger: Option<ide_plugin::PluginDebugger>,
) -> ide_plugin::PluginRunConfiguration {
ide_plugin::PluginRunConfiguration {
id: "app".into(),
name: "App".into(),
program: "tool".into(),
env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
debugger,
..Default::default()
}
}
#[test]
fn configurations_only_add_environment_variables() {
let root = std::env::temp_dir();
let ok = |c| validate_configurations(&root, &[c]).is_ok();
assert!(ok(launch(vec![("OPENIDEA_TEST_NEW_VARIABLE", "1")], None)));
let (inherited, _) = std::env::vars().next().expect("the test runs with an environment");
assert!(!ok(launch(vec![(inherited.as_str(), "x")], None)));
assert!(!ok(launch(vec![("A_B", "1"), ("A_B", "2")], None)));
for name in ["", "A=B", "A\0"] {
assert!(!ok(launch(vec![(name, "1")], None)), "{name:?}");
}
assert!(!ok(launch(vec![("OPENIDEA_TEST_NUL", "a\0")], None)));
let many: Vec<_> = (0..65).map(|i| (format!("OPENIDEA_TEST_{i}"), String::new())).collect();
let mut config = launch(vec![], None);
config.env = many;
assert!(!ok(config));
}
#[test]
fn configurations_name_a_language_or_an_attach_to_process_kind() {
use ide_plugin::PluginDebugger::{AttachChild, Launch};
let root = std::env::temp_dir();
let ok = |d| validate_configurations(&root, &[launch(vec![], Some(d))]).is_ok();
assert!(ok(Launch { language: "csharp".into() }));
assert!(!ok(Launch { language: String::new() }));
assert!(!ok(Launch { language: "c sharp".into() }));
for kind in ATTACH_KINDS {
assert!(ok(AttachChild { kind: kind.into(), command_contains: None }), "{kind}");
}
assert!(!ok(AttachChild { kind: "java".into(), command_contains: None }));
assert!(ok(AttachChild { kind: "dotnet".into(), command_contains: Some("Worker.dll".into()) }));
assert!(!ok(AttachChild { kind: "dotnet".into(), command_contains: Some(String::new()) }));
}
}
-10
Переглянути файл
@@ -57,7 +57,6 @@ pub const COMMANDS: &[&str] = &[
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_debug_attach_target",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
@@ -573,15 +572,6 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_debug_attach_target", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let (config, run) = (c.arg::<String>("config")?, c.arg("run")?);
blocking(move || plugins.debug_attach_target(&c.host, &c.window, &config, run)).await
}
});
let h = host.clone();
d.add("plugin_finish_operation", move |c| {
let h = h.clone();
async move {
-18
Переглянути файл
@@ -130,11 +130,6 @@ pub struct PluginRunDetection {
pub(crate) project_pins: super::api::ProjectPins,
}
/// `RunConfig.extras.params` keys carrying an API 1.9 configuration's debugger.
pub const DEBUG_LANGUAGE: &str = "debugLanguage";
pub const DEBUG_ATTACH: &str = "debugAttach";
pub const DEBUG_ATTACH_CONTAINS: &str = "debugAttachContains";
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
@@ -200,19 +195,6 @@ impl ExternalDetection for PluginRunDetection {
if let Some(framework) = &c.framework {
config.extras.params.insert("framework".into(), framework.clone());
}
config.env = c.env.iter().cloned().collect();
match &c.debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
config.extras.params.insert(DEBUG_LANGUAGE.into(), language.clone());
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
config.extras.params.insert(DEBUG_ATTACH.into(), kind.clone());
if let Some(text) = command_contains {
config.extras.params.insert(DEBUG_ATTACH_CONTAINS.into(), text.clone());
}
}
None => {}
}
configs.push(config);
}
}
+3 -38
Переглянути файл
@@ -88,9 +88,6 @@ impl Host {
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window, not a project: they follow it
// into the project's fresh document registry.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
// Reject a damaged manifest before replacing the currently open project.
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The window reloaded (or asked again) for the project it already has: keep
@@ -624,9 +621,6 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
@@ -709,29 +703,12 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let watch = dir.clone();
self.open_light_in(window, dir, Some(&watch))
}
/// LightEdit without a file (a welcome window's File › New File): rooted
/// at the user's home folder, watching nothing until a buffer is saved
/// into a real folder ([`Project::watch_light_dir`]).
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
self.open_light_in(window, dir, None)
}
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window: they follow it out of a project.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
if let Ok(current) = self.project(window)
&& current.light
{
if let Some(dir) = watch_dir {
current.watch_light_dir(dir);
}
current.watch_light_dir(&dir);
return Ok(current);
}
self.close_project(window);
@@ -750,16 +727,7 @@ impl Host {
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let watcher = match watch_dir {
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
// A quiet private folder stands in until the first save brings a
// real folder in.
None => {
let idle = self.local_data_dir.join("light-edit");
std::fs::create_dir_all(&idle)?;
light_watcher(&idle, &sink, &documents, &history)?
}
};
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
let notebooks = crate::notebook::NotebookService::new(
@@ -805,11 +773,8 @@ impl Host {
light: true,
root: dir,
});
tracing::info!("window {window} edits {} without a project", project.root.display());
tracing::info!("window {window} edits {} without a project", file.display());
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
Ok(project)
}
}
-7
Переглянути файл
@@ -218,13 +218,6 @@ impl Dispatcher {
})
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open_untitled", |c| async move {
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
})
.add(
"doc_save_as",
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
)
.add("doc_close", |c| async move {
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
})
+30 -143
Переглянути файл
@@ -296,142 +296,39 @@ impl crate::Host {
}
}
/// Commands allowed in Restricted Mode, by exact name: those which only inspect/edit source or
/// operate user-owned application state. A new command is denied until deliberately listed here.
/// Commands which only inspect/edit source or operate user-owned application state.
/// New execution commands are denied until deliberately classified here.
pub fn restricted_command_allowed(cmd: &str) -> bool {
if [
"fs_",
"doc_",
"syntax_",
"search_",
"nav_",
"history_",
"spell_",
"analysis_",
"structural_",
"refactor_",
"bytes_",
"window_",
"window_group_",
"term_",
"settings_",
"project_trust_",
// New Project writes only where the user chose and runs no code of an open project.
"scaffold_",
"ssh_",
"git_credential",
]
.iter()
.any(|p| cmd.starts_with(p))
{
return true;
}
matches!(
cmd,
"fs_compare_directories"
| "fs_copy"
| "fs_copy_merge"
| "fs_create_dir"
| "fs_create_file"
| "fs_create_file_bytes"
| "fs_delete"
| "fs_entry_kind"
| "fs_open_with_system"
| "fs_probe"
| "fs_read_dir"
| "fs_read_image"
| "fs_read_range"
| "fs_read_text"
| "fs_rename"
| "fs_resolve_reference"
| "fs_set_writable"
| "doc_close"
| "doc_edit"
| "doc_folding"
| "doc_highlights"
| "doc_open"
| "doc_open_untitled"
| "doc_open_virtual"
| "doc_outline"
| "doc_python_facts"
| "doc_save"
| "doc_save_as"
| "doc_save_all"
| "doc_selection_ranges"
| "doc_set_bom"
| "doc_set_encoding"
| "doc_set_line_ending"
| "doc_snapshot"
| "doc_sync_from_disk"
| "doc_ui_mode"
| "syntax_errors"
| "syntax_expression_at"
| "syntax_identifiers"
| "syntax_identifiers_in_files"
| "syntax_locals"
| "syntax_node_at"
| "syntax_structural_ranges"
| "syntax_support"
| "search_start"
| "search_validate"
| "nav_index_definitions"
| "nav_inheritance"
| "nav_resolve_module"
| "history_content"
| "history_list"
| "history_list_dir"
| "history_put_label"
| "history_restore"
| "spell_check"
| "spell_suggest"
| "analysis_locate_duplicates"
| "structural_languages"
| "structural_search"
| "structural_validate"
| "refactor_can_undo_file_move"
| "refactor_file_move_edits"
| "refactor_file_move_text_edits"
| "refactor_move_files"
| "refactor_undo_file_move"
| "window_close_context"
| "window_complete_launch"
| "window_cycle"
| "window_flash_taskbar"
| "window_group_cancel_close"
| "window_group_close"
| "window_group_close_tabs"
| "window_group_current"
| "window_group_detach"
| "window_group_merge"
| "window_group_reorder"
| "window_group_restore"
| "window_group_select"
| "window_is_fullscreen"
| "window_new"
| "window_open_comparison"
| "window_open_deployment"
| "window_open_diff"
| "window_open_file_in_new_window"
| "window_open_files"
| "window_open_project"
| "window_open_projects"
| "window_open_remote"
| "window_open_settings"
| "window_set_titlebar_height"
| "window_start_drag"
| "window_take_launch"
| "window_toggle_fullscreen"
| "window_toggle_zoom"
| "term_available_shells"
| "term_default_shell"
| "term_is_busy"
| "term_kill"
| "term_resize"
| "term_spawn"
| "term_store_image"
| "term_write"
| "settings_export"
| "settings_get"
| "settings_get_global"
| "settings_import"
| "settings_list_jetbrains_ides"
| "settings_read_jetbrains"
| "settings_set"
| "project_trust_add_location"
| "project_trust_list"
| "project_trust_remove_location"
| "project_trust_set"
| "project_trust_status"
// New Project writes only where the user chose and runs no code of an open project.
| "scaffold_catalog"
| "scaffold_create"
| "scaffold_options"
| "scaffold_requirements"
| "ssh_askpass_answer"
| "ssh_connected"
| "ssh_disconnect"
| "ssh_needs_secret"
| "ssh_open_terminal"
| "ssh_password_saved"
| "ssh_set_secret"
| "ssh_test"
| "ssh_trust_host"
| "git_credential_forget"
| "git_credentials"
| "app_info"
"app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
@@ -445,7 +342,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_has_image"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
@@ -505,7 +401,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "web_unresolved_references"
| "project_rename"
| "light_edit_open"
| "light_edit_open_untitled"
| "workspace_create"
| "workspace_discover_projects"
| "workspace_update"
@@ -676,14 +571,6 @@ mod tests {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn restricted_mode_denies_new_commands_whatever_their_name() {
for cmd in ["fs_new_command", "term_new_command", "ssh_new_command", "settings_new_command", "git_credential_x"]
{
assert!(!super::restricted_command_allowed(cmd), "{cmd} is allowed without being listed");
}
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
-70
Переглянути файл
@@ -179,73 +179,3 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_reach_the_disk_through_save_as() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let dir = tmp_root.join("notes");
let host = host(&tmp_root.join("app"));
// A window with no file (a welcome screen's File › New File): rooted at the
// home folder, no watcher yet, doc commands work.
let empty = host.open_light_empty("main").unwrap();
assert!(empty.light);
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
assert_eq!(untitled.language.as_str(), "plaintext");
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
// Save As: the buffer lands on the disk and turns into a file document.
let target = dir.join("deep/notes.md");
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
assert_eq!(saved.path, target);
assert_eq!(saved.language.as_str(), "markdown");
assert!(!saved.dirty);
// The platform's default line separator applies to new buffers.
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
ops::doc::save(&host, "main", untitled.id).await.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
// The saved file's folder is followed for external changes now.
host.shutdown().await;
}
fn untitled_edit(insert: &str) -> ChangeSet {
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_follow_the_window_into_a_project() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let host = host(&tmp_root.join("app"));
// A LightEdit window with an edited untitled buffer (and a clean one).
host.open_light_empty("main").unwrap();
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
// The window switches to a project: the buffers follow, contents and dirty flags kept.
let project_root = tmp_root.join("proj");
std::fs::create_dir_all(&project_root).unwrap();
let project = host.open_project("main", &project_root).unwrap();
assert!(!project.light);
let carried =
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
assert_eq!(snap.text, "{\"draft\": true}");
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
assert_eq!(snap.language.as_str(), "plaintext");
let clean_carried =
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
host.shutdown().await;
}
+1 -68
Переглянути файл
@@ -110,11 +110,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
let found = h.json("search_text", json!({"q":"needle","limit":2})).await;
assert_eq!(found["items"].as_array().unwrap().len(), 2);
assert_eq!(found["more"], true);
assert_eq!(found["nextOffset"], 2);
// The next page goes on where this one ended.
let every = h.json("search_text", json!({"q":"needle","limit":20})).await["items"].as_array().unwrap().clone();
let next = h.json("search_text", json!({"q":"needle","limit":2,"offset":2})).await;
assert_eq!(next["items"].as_array().unwrap()[..], every[2..every.len().min(4)]);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
@@ -167,9 +162,7 @@ async fn window_reports_supply_editor_selection_and_problems_then_disappear_on_c
assert_eq!(focused["selection"]["startColumn"], 3);
let problems = h.json("get_file_problems", json!({"filePath":"src/a.txt","errorsOnly":false})).await;
assert_eq!(problems["errors"][0]["description"], "reported warning");
assert_eq!(h.json("get_project_problems", json!({})).await["files"], json!([]), "errors only by default");
let all = h.json("get_project_problems", json!({"errorsOnly":false})).await;
assert_eq!((all["files"].as_array().unwrap().len(), &all["total"]), (1, &json!(1)));
assert_eq!(h.json("get_project_problems", json!({})).await["files"].as_array().unwrap().len(), 1);
h.host.close_project("main");
h.host.open_project("main", &h.root).unwrap();
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!([]));
@@ -294,41 +287,6 @@ async fn execution_requires_a_window_and_denial_never_runs_a_build() {
assert!(error.contains("window closed"), "{error}");
}
#[tokio::test(flavor = "multi_thread")]
async fn run_configurations_are_listed_a_page_at_a_time_and_filtered() {
let h = Harness::new();
h.trust().await;
let saved = |name: &str, command: &str, env: &[(&str, &str)]| ide_run::RunConfig {
id: format!("user:{name}"),
name: name.into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let configs =
[saved("build", "tsc", &[("MODE", "ci")]), saved("lint", "oxlint .", &[]), saved("test", "vitest", &[])];
ide_run::save_user_configs(&h.root, &configs).unwrap();
let all = h.json("get_run_configurations", json!({})).await;
let total = all["total"].as_u64().unwrap();
assert!(total >= 3, "{all}");
// A saved configuration's own environment is listed.
let build = all["configurations"].as_array().unwrap().iter().find(|c| c["name"] == "build").unwrap();
assert_eq!(build["environment"], json!({ "MODE": "ci" }));
let first = h.json("get_run_configurations", json!({"limit": 1})).await;
assert_eq!((first["configurations"].as_array().unwrap().len(), &first["nextOffset"]), (1, &json!(1)));
let lint = h.json("get_run_configurations", json!({"query": "OXLINT"})).await;
let found = lint["configurations"].as_array().unwrap();
assert!(!found.is_empty() && (found.len() as u64) < total, "{lint}");
assert!(found.iter().all(|c| c.to_string().to_lowercase().contains("oxlint")), "{lint}");
}
#[tokio::test(flavor = "multi_thread")]
async fn git_status_and_diff_on_a_temporary_repository() {
let h = Harness::new();
@@ -350,18 +308,6 @@ async fn git_status_and_diff_on_a_temporary_repository() {
std::fs::write(h.root.join("src/a.txt"), "changed\n").unwrap();
let status = h.json("git_status", json!({"repositoryPathRelativeToProject":"."})).await;
assert_eq!(status["repositories"][0]["unstagedCount"], 1);
// Two entries, one per page.
std::fs::write(h.root.join("src/new.txt"), "new\n").unwrap();
let page =
|offset: u64| h.json("git_status", json!({"repositoryPathRelativeToProject":".","limit":1,"offset":offset}));
let first = page(0).await["repositories"][0].clone();
assert_eq!(
(&first["totalEntries"], &first["hasMoreEntries"], &first["nextOffset"]),
(&json!(2), &json!(true), &json!(1))
);
let second = page(1).await["repositories"][0].clone();
assert_ne!(second["entries"][0], first["entries"][0]);
assert!(second.get("nextOffset").is_none(), "{second}");
assert!(h.call("git_diff", json!({"filePath":"src/a.txt"})).await.contains("+changed"));
assert!(h.call("git_diff", json!({})).await.contains("src/a.txt"));
assert!(h.call("git_diff", json!({"staged":true})).await.is_empty());
@@ -664,19 +610,6 @@ async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
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}");
// Newest first, brief, filtered by state and paged.
let newest = h.json("list_runs", json!({"limit": 1})).await;
assert_eq!(
(&newest["runs"][0]["name"], &newest["total"], &newest["nextOffset"]),
(&json!("_agent_server"), &json!(2), &json!(1))
);
assert!(newest["runs"][0].get("outputLength").is_none(), "{newest}");
assert_eq!(h.json("list_runs", json!({"state": "idle"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "running"})).await["total"], 0);
assert_eq!(h.json("list_runs", json!({"state": "stoppedByAgent"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "error"})).await["total"], 0);
let unknown = h.error("list_runs", json!({"state": "done"})).await;
assert!(unknown.contains("unknown state `done`"), "{unknown}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
+1 -7
Переглянути файл
@@ -6450,13 +6450,7 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
let configs = f.host.run_detection().detect(&f.project);
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
&& c.is_test
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")
&& c.env.get("OPENIDEA_EXECUTION_FIXTURE").map(String::as_str) == Some("1")
&& c.extras.params.get("debugLanguage").map(String::as_str) == Some("python")));
let worker = configs.iter().find(|c| c.id == "plugin:execution:worker").unwrap();
assert_eq!(worker.extras.params.get("debugAttach").map(String::as_str), Some("dotnet"));
assert_eq!(worker.extras.params.get("debugAttachContains").map(String::as_str), Some("Worker.dll"));
assert!(worker.env.is_empty() && !worker.is_test);
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
assert_eq!(
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
3
-1
Переглянути файл
@@ -46,7 +46,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_has_image",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
-19
Переглянути файл
@@ -207,22 +207,3 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn checkout_reports_a_branch_held_by_another_worktree() {
let f = Flow::new("rust-app").await;
let linked = f._tmp.path().join("linked");
git(&f.root, &["worktree", "add", "-q", "-b", "topic", linked.to_str().unwrap()]);
let outcome = ops::vcs::vcs_checkout(&f.host, WINDOW, None, "topic".into(), false, None).await.unwrap();
assert_eq!(
outcome,
ide_vcs::CheckoutOutcome::CheckedOutInWorktree {
branch: "topic".into(),
path: dunce::canonicalize(&linked).unwrap()
}
);
// Nothing changed: the project is still on main.
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "main" && b.current), "{branches:?}");
}
-56
Переглянути файл
@@ -1,56 +0,0 @@
; Shell scripts: drawn over the stream mode, which knows only fixed word lists.
; The last matching pattern wins.
[
(string)
(raw_string)
(ansi_c_string)
(translated_string)
(heredoc_body)
(heredoc_start)
] @string
[
(command_substitution)
(process_substitution)
(expansion)
(simple_expansion)
] @embedded
(comment) @comment
[
"case"
"do"
"done"
"elif"
"else"
"esac"
"fi"
"for"
"function"
"if"
"in"
"select"
"then"
"until"
"while"
"declare"
"export"
"local"
"readonly"
"typeset"
"unset"
"unsetenv"
] @keyword
(command_name) @function.command
(function_definition name: (word) @function.definition)
(variable_name) @variable
(special_variable_name) @variable
[
(number)
(file_descriptor)
] @number
+1 -1
Переглянути файл
@@ -314,7 +314,7 @@ pub(crate) static SPECS: &[Spec] = &[
name: "bash",
language: tree_sitter_bash::LANGUAGE,
outline: outline!("bash"),
highlights: Some(Highlights { query: highlights!("bash"), first_wins: false }),
highlights: None,
fold_nodes: &["do_group", "if_statement", "case_statement", "heredoc_body"],
fold_bodies: NONE,
imports: NONE,
+1 -29
Переглянути файл
@@ -43,13 +43,11 @@ pub enum SyntaxHighlightKind {
Operator,
Punctuation,
Parameter,
/// A shell command name (`pgrep`, a script's own function).
Command,
}
impl SyntaxHighlightKind {
/// Every kind, in `as u32` order: the legend for [`SyntaxHighlights::data`].
pub const ALL: [Self; 26] = [
pub const ALL: [Self; 25] = [
Self::Keyword,
Self::String,
Self::StringSpecial,
@@ -75,7 +73,6 @@ impl SyntaxHighlightKind {
Self::Operator,
Self::Punctuation,
Self::Parameter,
Self::Command,
];
/// The kebab-case name plugins use (`doc-comment`, `function-call`, …).
@@ -156,7 +153,6 @@ const CAPTURES: &[(&str, Paint)] = {
("function.builtin", Kind(K::Builtin)),
("function.macro", Kind(K::Macro)),
("function.definition", Kind(K::Function)),
("function.command", Kind(K::Command)),
("function", Kind(K::FunctionCall)),
("method", Kind(K::FunctionCall)),
("constructor", Kind(K::Type)),
@@ -412,30 +408,6 @@ mod tests {
}
}
#[test]
fn shell_scripts_highlight_declarations_commands_and_function_names() {
use SyntaxHighlightKind as K;
let src = "this::check_process() {\n local process\n process=\"${1:?}\"\n if [ -x \"$(command -v pgrep)\" ]; then\n if pgrep -f -q \"${process}\"; then\n functions::exit 1 \"'${process}' is running\"\n fi\n fi\n}\n# done\n";
let got = spans("shellscript", src);
for (text, expected) in [
("this::check_process", K::Function),
("local", K::Keyword),
("if", K::Keyword),
("command", K::Command),
("pgrep", K::Command),
("functions::exit", K::Command),
("process", K::Variable),
("# done", K::Comment),
] {
assert!(
got.iter().any(|(word, kind)| word == text && *kind == expected),
"missing {text}: {expected:?} in {got:?}"
);
}
// An expansion inside a string is not string-coloured.
assert!(!got.iter().any(|(word, kind)| word.contains("${process}") && *kind == K::String), "{got:?}");
}
#[test]
fn non_kotlin_parameters_keep_variable_highlighting() {
for (lang, src) in [
+16 -18
Переглянути файл
@@ -715,35 +715,33 @@ mod tests {
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
// The SQL server's failed launch reports its status from a runtime thread; while that
// thread drains the server's event queue, events emitted here reach the sink from it.
let latest = |want: &dyn Fn(&[ide_core::Diagnostic]) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let items = events.lock().iter().rev().find_map(|e| match e {
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
});
match items {
Some(items) if want(&items) => return items,
_ if std::time::Instant::now() > deadline => panic!("latest host diagnostics: {items:?}"),
_ => std::thread::sleep(Duration::from_millis(5)),
}
}
})
.unwrap()
};
let messages = |items: &[ide_core::Diagnostic]| items.iter().map(|d| d.message.clone()).collect::<Vec<_>>();
let merged = latest(&|items| items.len() == 3);
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
assert!(
merged.iter().filter(|d| d.message != "native").all(|d| d.source.as_deref() == Some(server.name.as_str()))
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
latest(&|items| messages(items) == ["native", "b"]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
latest(&|items| messages(items) == ["native"]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
-33
Переглянути файл
@@ -1767,38 +1767,6 @@ impl LspManager {
}
}
/// Library classes in `jar:` targets that have no cached sources jar: the official Kotlin
/// LSP decompiles them to signatures only (`/* compiled code */`). When jdtls is running,
/// the same class from its Java model instead, decompiled with method bodies and navigable.
/// Its documents belong to jdtls ([`Self::virtual_document`] records it).
async fn jdt_library_classes(&self, snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
let jar_class = |l: &Location| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"));
if !locs.iter().any(jar_class)
|| !self.servers.lock().iter().any(|s| s.name == jdtls::SERVER && s.running().is_some())
{
return locs;
}
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
let mut out = Vec::with_capacity(locs.len());
for loc in locs {
let entry = loc.uri.as_deref().filter(|_| jar_class(&loc)).and_then(library_source::parse);
let Some(entry) = entry else {
out.push(loc);
continue;
};
let Ok(uri) = self.java_class_file(Path::new(""), Some((entry.jar, entry.entry))).await else {
out.push(loc);
continue;
};
let range = match (&name, self.virtual_document(&uri).await) {
(Some(name), Ok(doc)) => library_source::declaration_range(&doc.text, name),
_ => Range::default(),
};
out.push(Location { path: uri.clone().into(), range, uri: Some(uri) });
}
out
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1840,7 +1808,6 @@ impl LspManager {
let locs = library_sources(snap, &self.project_root, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = self.jdt_library_classes(snap, pos, locs).await;
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
+28 -96
Переглянути файл
@@ -296,48 +296,37 @@ fn harness_configured(
servers: &[(&str, Value)],
adjust: impl Fn(&mut LanguageServerConfig),
) -> Harness {
harness_languages(&[(lang_id, ext, servers)], adjust)
}
/// A language as `(id, extension, servers)`.
type MockLanguage<'a> = (&'a str, &'a str, &'a [(&'a str, Value)]);
/// Several languages; documents open as the first.
fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut LanguageServerConfig)) -> Harness {
let dir = tempfile::tempdir().unwrap();
// Edits and references report canonical paths.
let root = dunce::canonicalize(dir.path()).unwrap();
let configs = languages
.iter()
.map(|(lang_id, ext, servers)| LanguageConfig {
id: LanguageId::new(*lang_id),
name: "Mock".into(),
extensions: vec![ext.to_string()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
})
.collect();
let registry = Arc::new(LanguageRegistry::from_configs(configs));
let lang = LanguageConfig {
id: LanguageId::new(lang_id),
name: "Mock".into(),
extensions: vec![ext.into()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let launcher = Arc::new(MockLauncher::default());
for (name, caps) in languages.iter().flat_map(|(_, _, servers)| servers.iter()) {
for (name, caps) in servers {
*launcher.state(name).caps.lock() = caps.clone();
}
let events = Arc::new(Mutex::new(Vec::new()));
@@ -346,7 +335,7 @@ fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut Langua
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(languages[0].0) }
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(lang_id) }
}
impl Harness {
@@ -2957,63 +2946,6 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
}
#[tokio::test(flavor = "multi_thread")]
async fn library_classes_without_sources_open_decompiled_by_a_running_jdtls() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let jar = dunce::canonicalize(repo.path()).unwrap().join("acme/lib/1.0/lib-1.0.jar");
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
zip.start_file("acme/Thing.class", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"\0").unwrap();
zip.finish().unwrap();
let class = format!("jar://{}!/acme/Thing.class", uri_of(&jar).trim_start_matches("file://"));
let escaped = jar.to_string_lossy().replace('/', "%5C/");
let jdt = format!("jdt://contents/lib-1.0.jar/acme/Thing.class?=app/{escaped}%3Cacme%28Thing.class");
let decompiled =
"package acme;\n\npublic class Thing {\n public void run() {\n System.out.println();\n }\n}\n";
let mut kotlin = incremental_caps();
kotlin["executeCommandProvider"] = json!({ "commands": ["decompile"] });
let mut java = incremental_caps();
java["workspaceSymbolProvider"] = json!(true);
let h =
harness_languages(&[("kotlin", "kt", &[("kotlin-lsp", kotlin)]), ("java", "java", &[("jdtls", java)])], |_| {});
let (doc, _) = h.open("Main.kt", "val thing = Thing()\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let k = h.launcher.state("kotlin-lsp");
k.respond(
"textDocument/definition",
json!({ "uri": class, "range": {"start":{"line":0,"character":6},"end":{"line":0,"character":11}} }),
);
let r = json!({"start":{"line":2,"character":13},"end":{"line":2,"character":18}});
// jdtls not running: the Kotlin server's own target and decompiler.
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].uri.as_deref(), Some(class.as_str()));
let path = h.root.join("Sample.java");
std::fs::write(&path, "class Sample {}").unwrap();
h.mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class Sample {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let j = h.launcher.state("jdtls");
j.respond(
"workspace/symbol",
json!([{ "name": "Thing", "kind": 5, "containerName": "acme", "location": { "uri": jdt, "range": r } }]),
);
j.respond("java/classFileContents", json!(decompiled));
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(
defs,
vec![ide_core::Location { path: jdt.clone().into(), range: rng(2, 13, 2, 18), uri: Some(jdt.clone()) }]
);
assert_eq!(j.requested("workspace/symbol")[0]["query"], "acme.Thing");
let vd = h.mgr.virtual_document(&jdt).await.unwrap();
assert_eq!(vd.text, decompiled);
assert!(k.requested("workspace/executeCommand").is_empty(), "the Kotlin stub is not asked for");
}
#[tokio::test(flavor = "multi_thread")]
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
-18
Переглянути файл
@@ -61,24 +61,6 @@ pub struct PluginRunConfiguration {
pub cwd: Option<String>,
pub test: bool,
pub framework: Option<String>,
/// API 1.9: variables added to the run's environment.
#[serde(default)]
pub env: Vec<(String, String)>,
/// API 1.9: how Debug starts the configuration.
#[serde(default)]
pub debugger: Option<PluginDebugger>,
}
/// How Debug starts a plugin configuration (API 1.9 `launch-configurations.debugger`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum PluginDebugger {
/// `program` under the debugger of this language id.
#[serde(rename_all = "camelCase")]
Launch { language: String },
/// Run, then attach to the first matching process the run starts.
#[serde(rename_all = "camelCase")]
AttachChild { kind: String, command_contains: Option<String> },
}
/// RunExited data exchanged with the IDE.
+23 -62
Переглянути файл
@@ -36,7 +36,6 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use crate::wit_host::launch_configurations_bindings::exports::openidea::plugin::launch_configurations as wit_launch;
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
use bindings::openidea::plugin::events as wit_events;
@@ -56,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.9";
pub const API_VERSION: &str = "1.8";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -1009,7 +1008,6 @@ struct Exports {
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
run_configurations: Option<wit_configs::Guest>,
launch_configurations: Option<wit_launch::Guest>,
test_results: Option<wit_tests::Guest>,
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
}
@@ -1149,7 +1147,6 @@ impl Runtime {
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
let launch_indices = wit_launch::GuestIndices::new(&pre).ok();
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
let validation_indices = wit_rtv::GuestIndices::new(&pre).ok();
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
@@ -1160,8 +1157,6 @@ impl Runtime {
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let run_configurations =
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let launch_configurations =
launch_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
@@ -1235,7 +1230,6 @@ impl Runtime {
inlay,
endpoints,
run_configurations,
launch_configurations,
test_results,
mcp,
file_actions,
@@ -1275,67 +1269,34 @@ impl Runtime {
.map(|r| r.map_err(|message| clip(message, 4096)))
}
/// Complete configurations from the optional API 1.9 export, else the API 1.4 one.
/// Complete configurations from the optional API 1.4 export.
pub fn run_configurations(
&mut self,
root: String,
timeout: Duration,
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items: Vec<crate::PluginRunConfiguration> = if self.exports.launch_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.launch_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
env: c.env,
debugger: match c.debugger {
Some(wit_launch::Debugger::Launch(language)) => {
Some(crate::PluginDebugger::Launch { language })
}
Some(wit_launch::Debugger::AttachChild(a)) => Some(crate::PluginDebugger::AttachChild {
kind: a.kind,
command_contains: a.command_contains,
}),
Some(wit_launch::Debugger::Other(_)) | None => None,
},
})
.collect()
} else if self.exports.run_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
..Default::default()
})
.collect()
} else {
if self.exports.run_configurations.is_none() {
return Err(CallError::missing_export("run-configurations"));
};
Ok(items)
}
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
Ok(items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
})
.collect())
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
-5
Переглянути файл
@@ -2254,11 +2254,6 @@ impl bindings::openidea::plugin::run::Host for StoreData {
pub(crate) mod run_configurations_bindings {
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
}
pub(crate) mod launch_configurations_bindings {
wasmtime::component::bindgen!({ world: "launch-configurations-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod test_results_bindings {
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.9.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0", "1.0.0-rc.1"] {
let host = v("1.8.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.10.0"), &host).unwrap_err();
assert!(newer.contains("1.10") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.9 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
+1 -1
Переглянути файл
@@ -1,7 +1,7 @@
id = "execution"
name = "Plugin execution tests"
version = "0.1.0"
engines.openidea = "^1.9"
engines.openidea = "^1.5"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
+12 -32
Переглянути файл
@@ -114,37 +114,17 @@ impl Plugin for Execution {
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
impl LaunchConfigurations for Execution {
fn launch_configurations(&mut self, _: &str) -> Vec<PluginLaunchConfiguration> {
vec![
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
},
env: vec![("OPENIDEA_EXECUTION_FIXTURE".into(), "1".into())],
debugger: Some(Debugger::Launch { language: "python".into() }),
},
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "worker".into(),
name: "Worker".into(),
program: "execution-tool".into(),
args: vec!["start".into()],
..Default::default()
},
env: Vec::new(),
debugger: Some(Debugger::AttachChild {
kind: "dotnet".into(),
command_contains: Some("Worker.dll".into()),
}),
},
]
impl RunConfigurations for Execution {
fn configurations(&mut self, _: &str) -> Vec<PluginRunConfiguration> {
vec![PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
}]
}
}
impl TestResults for Execution {
@@ -201,4 +181,4 @@ impl RunTargetValidation for Execution {
}
}
}
register_plugin!(Execution, launch_configurations, test_results, run_target_validation);
register_plugin!(Execution, run_configurations, test_results, run_target_validation);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.9.0;
export openidea:plugin/providers@1.9.0;
export openidea:plugin/file-actions@1.9.0;
include openidea:plugin/plugin-syntax@1.8.0;
export openidea:plugin/providers@1.8.0;
export openidea:plugin/file-actions@1.8.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
1e11a95e0909d18308d21a9a8448f8b2e427d240d5d8cd13d8ddbe6ba5009237
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
d5cd77130cc97cffdbc742f04e3e1196f14160107b636806c6ef3c9b40724e84
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
a0f74430b0a7d9ff48524d7aae4f8a0bd0fc60511e2a951dde9991b9bf7d15a9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
65508f6e25bd0efc85820ae7d0f9037b58c778fa61e8498c5b9a5055cc38f6fc
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
c86ed41d68c132f72eb8b5794f31b975492e5ad42e41b7823d1bf28ba118a262
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
3da1a09906d803234016b3f9c30a88b9b40bf3187fc73840ff60a2e05f504b48
Loaded 100 of 436 files, more files were not shown because too many files have changed in this diff. Show more