дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 22:25:48 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
54e3b63547 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -42,7 +42,6 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
shortcuts live in `src/platform/keymaps/`.
|
||||
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
|
||||
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
|
||||
`gap` uses the `--space-*` scale (`src/styles/tokens.css`, 4px grid) where a value is on it.
|
||||
- User-visible text goes through the text catalog: add every new string to both
|
||||
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
|
||||
Other locales may lack entries; their UI shows the English text until a translator adds it.
|
||||
|
||||
Створено
@@ -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]]
|
||||
@@ -4578,7 +4577,6 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"ide-text",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
@@ -4799,7 +4797,6 @@ name = "ide-sdk"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-run",
|
||||
@@ -4809,7 +4806,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
@@ -4966,7 +4962,6 @@ dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"gix",
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4977,7 +4972,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
|
||||
@@ -90,8 +90,6 @@ dunce = "1"
|
||||
dirs = "6"
|
||||
memmap2 = "0.9"
|
||||
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English · <a href="README.zh-CN.md">简体中文</a> · <a href="README.tr.md">Türkçe</a>
|
||||
English · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
|
||||
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Bildiğin IDE, Rust ile yeniden yazıldı.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a> · Türkçe
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>İndir</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh">Belgeler</a>
|
||||
·
|
||||
<a href="#performans-karşılaştırması">Performans</a>
|
||||
·
|
||||
<a href="#eklentiler">Eklentiler</a>
|
||||
·
|
||||
<a href="#topluluk">Topluluk</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA, IntelliJ IDEA'nın çalışma biçimini (kısayol tuşlarını, araç pencerelerini,
|
||||
her şeyi arama alışkanlığını) çekirdeği Rust ile yazılmış yerel bir uygulamaya taşır.
|
||||
|
||||
Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı ve HTTP istemcileri;
|
||||
60'tan fazla dil için. macOS, Windows ve Linux sürümleri var.
|
||||
|
||||
## Performans karşılaştırması
|
||||
|
||||
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
|
||||
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| ---------------------------------- | --------------------- | ---------------------- |
|
||||
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
|
||||
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
|
||||
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
|
||||
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
|
||||
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
|
||||
| Kapanma | **0,3 sn** | 5,9 sn |
|
||||
|
||||
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
|
||||
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
|
||||
IDEA, Python'u daha derinlemesine analiz eder; yani "hazır" iki tarafta aynı işi ifade etmez.
|
||||
İkisi de dışarıdan, aynı yöntemle ölçüldü: kendi makinende ve projelerinde denemek için
|
||||
[`perf/compare`](perf/compare) klasörüne bak.
|
||||
|
||||
## Eklentiler
|
||||
|
||||
Eklentiler; eylemler, kod tamamlama, hızlı düzeltmeler, araç pencereleri, özel editörler,
|
||||
temalar, dosya simgeleri ve diller ekler. Korumalı (sandbox) WebAssembly bileşenleri olarak çalışırlar;
|
||||
temalar, simge temaları ve diller için hiç kod yazmak gerekmez.
|
||||
|
||||
Eklentileri Ayarlar › Eklentiler (Settings › Plugins) bölümünden kur ya da
|
||||
`openidea-plugin new` ile kendininkini başlat. Ayrıntılar için [OpenIDEA eklentisi yazmak](https://openidea.sh/en/plugins) sayfasına ve
|
||||
[resmi eklentilere](plugins/official) bak. Eklenti API'si hâlâ önizleme aşamasında.
|
||||
|
||||
## Topluluk
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP): sorular, geri bildirim ve sürüm haberleri.
|
||||
- WeChat grubu, katılmak için kodu okut:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="WeChat grubu QR kodu" width="200">
|
||||
|
||||
- Katkıda bulunmak için: [CONTRIBUTING.md](CONTRIBUTING.md) ve [katkıcı belgeleri](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Lisans
|
||||
|
||||
[Apache 2.0](LICENSE). Pakete dahil üçüncü taraf varlıklar [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dosyasında listelenir.
|
||||
|
||||
<sub>JetBrains ile bağlantılı değildir. IntelliJ IDEA, JetBrains s.r.o.'nun ticari markasıdır.</sub>
|
||||
@@ -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);
|
||||
@@ -1861,24 +1698,12 @@ impl Task {
|
||||
let s = self.state.lock();
|
||||
s.busy && s.task.status == AgentStatus::Running
|
||||
};
|
||||
let Some((live, method)) = live
|
||||
.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images))
|
||||
.and_then(|l| steer_method(&l.init).map(|m| (l, m)))
|
||||
else {
|
||||
let Some(live) = live.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images)) else {
|
||||
return Err(prompt);
|
||||
};
|
||||
let content = self.content(&live, &prompt).await;
|
||||
let sent: Result<Value, RpcError> =
|
||||
live.peer.request(method, json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
// `_session/steering` answers with an outcome: only `injected` joined the turn
|
||||
// (`promptRequired`: no turn is running any more).
|
||||
let sent = match sent {
|
||||
Ok(reply) => match reply.get("outcome").and_then(Value::as_str) {
|
||||
None | Some("injected") => Ok(()),
|
||||
Some(outcome) => Err(reply.get("reason").and_then(Value::as_str).unwrap_or(outcome).to_owned()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
live.peer.request("_openidea/steer", json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
if let Err(e) = sent {
|
||||
// The turn ended meanwhile: the message waits for the next one instead.
|
||||
tracing::debug!("agent task {}: steering failed: {e}", self.id);
|
||||
@@ -1932,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 {
|
||||
@@ -2399,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()))
|
||||
@@ -2619,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 = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
@@ -2877,29 +2600,14 @@ fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<Agent
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The method that adds a message to the running turn: OpenIDEA's own, or the
|
||||
/// `_session/steering` extension an agent advertises in the response's top-level `_meta`
|
||||
/// (OMP).
|
||||
fn steer_method(init: &acp::Negotiated) -> Option<&'static str> {
|
||||
let supported = |v: Option<&Value>| v.and_then(Value::as_bool) == Some(true);
|
||||
if supported(init.meta("/openidea/steer")) {
|
||||
Some("_openidea/steer")
|
||||
} else if supported(init.meta.as_ref().and_then(|m| m.pointer("/steering/supported"))) {
|
||||
Some("_session/steering")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
|
||||
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
|
||||
let capabilities = AgentCapabilities {
|
||||
images: init.prompt("image"),
|
||||
fork: init.session("fork"),
|
||||
rollback: meta("/openidea/rollback"),
|
||||
steer: steer_method(init).is_some(),
|
||||
steer: meta("/openidea/steer"),
|
||||
background_tasks: meta("/openidea/backgroundTasks"),
|
||||
read_only: false,
|
||||
};
|
||||
(capabilities, init.prompt("embeddedContext"))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -51,9 +51,6 @@ struct FakeAgent {
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
model_options: Mutex<HashMap<String, Vec<Value>>>,
|
||||
/// Advertises steering as OMP does (`_meta.steering` at the top of `initialize`)
|
||||
/// instead of `_meta.openidea.steer`.
|
||||
session_steering: bool,
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
@@ -159,10 +156,6 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
if self.session_steering {
|
||||
return Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true},
|
||||
"_meta": {"steering": {"supported": true}}}));
|
||||
}
|
||||
Ok(
|
||||
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
|
||||
@@ -190,13 +183,6 @@ impl Handler for FakeAgent {
|
||||
self.calls.lock().push(format!("{method} {detail}"));
|
||||
Ok(json!({}))
|
||||
}
|
||||
"_session/steering" => {
|
||||
let text = params["prompt"][0]["text"].clone();
|
||||
self.calls.lock().push(format!("{method} {text}"));
|
||||
// OMP answers whether the message joined the turn rather than failing.
|
||||
let outcome = if text == "too late" { "promptRequired" } else { "injected" };
|
||||
Ok(json!({"outcome": outcome, "queue": "steering"}))
|
||||
}
|
||||
"session/load" => {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
@@ -1074,42 +1060,6 @@ fn text(t: &str) -> AgentPrompt {
|
||||
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steers_through_session_steering() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { session_steering: true, ..FakeAgent::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("wait"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.steer);
|
||||
|
||||
mgr.prompt(&task.id, text("also this"), true).await.unwrap();
|
||||
assert!(agent.calls.lock().iter().any(|c| c == "_session/steering \"also this\""));
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(matches!(entries.last(), Some(AgentEntry::User { steered: true, .. })));
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
|
||||
// The agent didn't take it into the turn: the turn stops and it goes next, as without
|
||||
// steering (the fake agent's next turn asks for permission).
|
||||
mgr.prompt(&task.id, text("too late"), true).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: false, .. } if text == "too late")));
|
||||
assert!(!entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: true, .. } if text == "too late")));
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2261,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 {
|
||||
@@ -2893,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()
|
||||
}
|
||||
@@ -2916,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();
|
||||
@@ -3001,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()
|
||||
}
|
||||
@@ -3288,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();
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(>k::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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -42,8 +42,6 @@ pub struct Settings {
|
||||
pub terminal: TerminalSettings,
|
||||
pub actions_on_save: ActionsOnSaveSettings,
|
||||
pub external_tools: Vec<ExternalTool>,
|
||||
/// Tools › Diff & Merge › External Diff Tools.
|
||||
pub external_diff: ExternalDiffSettings,
|
||||
/// Language servers / debug adapters at a user-chosen location, by the
|
||||
/// command the language definition launches (`elixir-ls`, `codelldb`).
|
||||
pub tools: BTreeMap<String, ToolSettings>,
|
||||
@@ -88,18 +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,
|
||||
/// While the agent works, playful words fill the pauses in what it reports.
|
||||
pub playful_status: bool,
|
||||
/// Seconds the agent reports nothing new before the playful words take over; 0 shows them
|
||||
/// from the start.
|
||||
pub playful_after_seconds: u32,
|
||||
/// The user's playful words by activity (`working`, `thinking`, `reading`, …), replacing
|
||||
/// the built-in ones of that activity; an empty list keeps its status factual.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = BTreeMap<String, Vec<String>>)]
|
||||
pub playful_words: BTreeMap<String, Vec<String>>,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ThinkingDisplay)]
|
||||
pub thinking: ThinkingDisplay,
|
||||
@@ -141,10 +127,6 @@ impl Default for AgentSettings {
|
||||
show_cost: false,
|
||||
fold_turns: true,
|
||||
smooth_text: true,
|
||||
fit_window_width: false,
|
||||
playful_status: false,
|
||||
playful_after_seconds: 0,
|
||||
playful_words: BTreeMap::new(),
|
||||
thinking: ThinkingDisplay::Auto,
|
||||
tool_calls: ToolCallDisplay::Grouped,
|
||||
expand_tool_output: false,
|
||||
@@ -484,7 +466,6 @@ impl Default for Settings {
|
||||
debugger: DebuggerSettings::default(),
|
||||
run: RunSettings::default(),
|
||||
terminal: TerminalSettings::default(),
|
||||
external_diff: ExternalDiffSettings::default(),
|
||||
actions_on_save: ActionsOnSaveSettings::default(),
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
@@ -989,7 +970,6 @@ pub struct LanguagesSettings {
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
pub zig: ZigSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
@@ -1012,7 +992,6 @@ impl Default for LanguagesSettings {
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
zig: ZigSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1224,13 +1203,6 @@ impl Default for RustSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ZigSettings {
|
||||
/// Directory holding the `zig` executable; empty = the one on `PATH`.
|
||||
pub zig: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoSettings {
|
||||
@@ -1743,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);
|
||||
}
|
||||
|
||||
@@ -175,24 +175,6 @@ impl Default for VcsConfirmationSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › Diff & Merge › External Diff Tools: a program that shows two files' differences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExternalDiffSettings {
|
||||
/// Offer Show Diff in External Tool in diff viewers.
|
||||
pub enabled: bool,
|
||||
/// The program to start.
|
||||
pub path: String,
|
||||
/// Its arguments: `%1` the left file, `%2` the right file, `%3` the diff's title.
|
||||
pub parameters: String,
|
||||
}
|
||||
|
||||
impl Default for ExternalDiffSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, path: String::new(), parameters: "%1 %2 %3".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Version Control › Shelf.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -345,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,
|
||||
}
|
||||
@@ -358,7 +338,6 @@ impl Default for RunSettings {
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
pin_tabs: false,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub(crate) mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
mod page;
|
||||
mod reports;
|
||||
mod search;
|
||||
mod symbols;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()) }));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,9 +1275,7 @@ impl Project {
|
||||
}
|
||||
let defaults = self.documents.file_defaults();
|
||||
let fallback = defaults.fallback_for(path);
|
||||
// The working file only when it is a regular file: a path that is a directory there
|
||||
// (a committed symlink to a folder, a file replaced by a folder) has only its blob.
|
||||
if path.is_file() {
|
||||
if path.exists() {
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
|
||||
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
|
||||
|
||||
@@ -296,144 +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"
|
||||
// The user's own diff program (global settings) on the diff's two sides.
|
||||
| "diff_external_tool"
|
||||
| "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"
|
||||
@@ -447,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"
|
||||
@@ -678,14 +572,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();
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -111,49 +111,3 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
/// A committed symlink to a folder (#3528): its revision is the link target, even though the
|
||||
/// working path is a directory.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_directory_symlink_revision_is_its_target() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(root.join("target")).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
std::fs::write(root.join("target/file.txt"), "example\n").unwrap();
|
||||
std::os::unix::fs::symlink("target", root.join("dir-link")).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "Add a directory symlink"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let link = root.join("dir-link");
|
||||
assert!(link.is_dir());
|
||||
assert_eq!(
|
||||
vcs::vcs_file_at(&host, "main", link.clone(), Some("HEAD".into())).await.unwrap().as_deref(),
|
||||
Some("target")
|
||||
);
|
||||
assert_eq!(vcs::vcs_file_mode(&host, "main", link, Some("HEAD".into())).await.unwrap().as_deref(), Some("120000"));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -78,8 +78,8 @@ impl Needle {
|
||||
let mut b = 0;
|
||||
if n.eq_ignore_ascii_case(q) {
|
||||
b += EXACT;
|
||||
} else if let Some(dot) = n.iter().position(|&b| b == b'.')
|
||||
&& n[..dot].eq_ignore_ascii_case(q)
|
||||
} else if let Some(dot) = name.find('.')
|
||||
&& name.as_bytes()[..dot].eq_ignore_ascii_case(q)
|
||||
{
|
||||
b += EXACT_STEM;
|
||||
} else if n.len() >= q.len() && n[..q.len()].eq_ignore_ascii_case(q) {
|
||||
@@ -97,31 +97,17 @@ fn hump_bonus(s: &str, indices: &[u32]) -> u32 {
|
||||
if indices.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// One pass over `s`: `indices` is sorted, and whether position `i` is a
|
||||
// word start only needs the char before it, so no `Vec<char>` is collected.
|
||||
let is_start = |prev: Option<char>, c: char| match prev {
|
||||
None => true,
|
||||
Some(p) => {
|
||||
!p.is_alphanumeric()
|
||||
|| (p.is_lowercase() && c.is_uppercase())
|
||||
|| (!p.is_ascii_digit() && c.is_ascii_digit())
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let is_start = |i: usize| {
|
||||
if i == 0 {
|
||||
return true;
|
||||
}
|
||||
let (p, c) = (chars[i - 1], chars[i]);
|
||||
!p.is_alphanumeric() || (p.is_lowercase() && c.is_uppercase()) || (!p.is_ascii_digit() && c.is_ascii_digit())
|
||||
};
|
||||
let (mut humps, mut next, mut first_start) = (0u32, 0usize, false);
|
||||
let (mut prev, mut at) = (None, 0u32);
|
||||
for c in s.chars() {
|
||||
if next < indices.len() && indices[next] == at {
|
||||
let started = is_start(prev, c);
|
||||
humps += u32::from(started);
|
||||
if next == 0 {
|
||||
first_start = started;
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
prev = Some(c);
|
||||
at += 1;
|
||||
}
|
||||
humps * HUMP + u32::from(first_start) * FIRST_AT_START
|
||||
let humps = indices.iter().filter(|&&i| (i as usize) < chars.len() && is_start(i as usize)).count() as u32;
|
||||
let first = if is_start(indices[0] as usize) { FIRST_AT_START } else { 0 };
|
||||
humps * HUMP + first
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
||||
@@ -16,7 +16,6 @@ toml.workspace = true
|
||||
tree-sitter.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
memchr = "2"
|
||||
regex-lite = "0.1"
|
||||
blake3.workspace = true
|
||||
tree-sitter-language = "0.1"
|
||||
|
||||
@@ -170,11 +170,6 @@ pub struct LanguageServerConfig {
|
||||
/// basedpyright): only used while none of those is installed.
|
||||
#[serde(default)]
|
||||
pub fallback: bool,
|
||||
/// Names of servers listed after this one that it makes redundant (a PHP server
|
||||
/// with its own Laravel support replaces `laravel-lsp`): they aren't used while
|
||||
/// this one serves the file.
|
||||
#[serde(default)]
|
||||
pub replaces: Vec<String>,
|
||||
/// What the server is used for (`completion`, `code-action`, `diagnostics`,
|
||||
/// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other
|
||||
/// capabilities it announces are ignored, so a refactoring-only server next
|
||||
|
||||
@@ -758,10 +758,7 @@ mod tests {
|
||||
let cfg = reg.get(&LanguageId::new("php")).unwrap();
|
||||
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
|
||||
// After the fallback: hover, rename and symbols stay with Intelephense.
|
||||
assert_eq!(names, ["intelephense", "phpactor", "phoxite", "laravel-lsp"]);
|
||||
// Phoxite answers the Laravel strings itself.
|
||||
let phoxite = cfg.language_servers.iter().find(|s| s.name == "phoxite").unwrap();
|
||||
assert!(phoxite.fallback && phoxite.replaces == ["laravel-lsp"]);
|
||||
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
|
||||
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
|
||||
assert_eq!(lsp.command.as_str(), "laravel-lsp");
|
||||
// Its `lsp` command takes no options and speaks over stdio; `--stdio` stops it at startup.
|
||||
|
||||
@@ -79,16 +79,11 @@ impl<'a> LineIndex<'a> {
|
||||
}
|
||||
|
||||
fn memchr_newlines(bytes: &[u8]) -> impl Iterator<Item = usize> + '_ {
|
||||
memchr::memchr_iter(b'\n', bytes).map(|i| i + 1)
|
||||
bytes.iter().enumerate().filter(|&(_, &b)| b == b'\n').map(|(i, _)| i + 1)
|
||||
}
|
||||
|
||||
pub(crate) fn utf16_len(s: &str) -> u32 {
|
||||
if s.is_ascii() {
|
||||
return s.len() as u32;
|
||||
}
|
||||
// One unit per char (each starts with a non-continuation byte), two for
|
||||
// 4-byte chars; byte classification vectorizes, unlike decoding `chars()`.
|
||||
s.as_bytes().iter().map(|&b| u32::from((b & 0xC0) != 0x80) + u32::from(b >= 0xF0)).sum()
|
||||
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
|
||||
@@ -660,17 +660,15 @@ impl LspManager {
|
||||
/// The servers serving `path`: every regular one, and each `fallback` one
|
||||
/// only while all servers listed before it have given up (missing, not
|
||||
/// launchable, or crashed for good). One that requires a root marker serves
|
||||
/// only files below one, and stands in for no other. One that a server before
|
||||
/// it `replaces` is left out while that server serves the file.
|
||||
/// only files below one, and stands in for no other.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
if let Some(servers) = self.arduino_servers(language, path) {
|
||||
return servers;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut available_before = false;
|
||||
let mut replaced: Vec<String> = Vec::new();
|
||||
for sc in &self.language_servers(language) {
|
||||
if (sc.fallback && available_before) || replaced.contains(&sc.name) {
|
||||
if sc.fallback && available_before {
|
||||
continue;
|
||||
}
|
||||
if sc.require_root_marker
|
||||
@@ -680,9 +678,6 @@ impl LspManager {
|
||||
}
|
||||
let server = self.server_for(sc, language, path);
|
||||
available_before |= !sc.require_root_marker && !server.gave_up();
|
||||
if !server.gave_up() {
|
||||
replaced.extend(sc.replaces.iter().cloned());
|
||||
}
|
||||
out.push(server);
|
||||
}
|
||||
out
|
||||
@@ -870,7 +865,7 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Missing,
|
||||
Some(message),
|
||||
) {
|
||||
self.fallbacks_take_over().await;
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -883,7 +878,7 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
) {
|
||||
self.fallbacks_take_over().await;
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -896,7 +891,7 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
) {
|
||||
self.fallbacks_take_over().await;
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1044,7 +1039,7 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
// Gave up (crashed repeatedly, or its build expired): the fallbacks it hid take over.
|
||||
None => self.fallbacks_take_over().await,
|
||||
None => self.attach_fallbacks(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,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,
|
||||
@@ -1845,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);
|
||||
@@ -3469,10 +3431,7 @@ impl LspManager {
|
||||
virtual_doc::is_decompiled(&text)
|
||||
};
|
||||
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
|
||||
let mut detected = self.languages.detect(Path::new(&title), None);
|
||||
if let Some(stem) = title.strip_suffix(".class").filter(|_| virtual_doc::is_java_class_stub(&text)) {
|
||||
detected = self.languages.detect(Path::new(&format!("{stem}.java")), None);
|
||||
}
|
||||
let detected = self.languages.detect(Path::new(&title), None);
|
||||
let language_id = if detected == LanguageId::plain_text() {
|
||||
languages.first().cloned().unwrap_or(detected)
|
||||
} else {
|
||||
@@ -3836,16 +3795,8 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// A server gave up: its fallbacks take over the open documents, and the servers
|
||||
/// they replace leave.
|
||||
async fn fallbacks_take_over(&self) {
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
}
|
||||
|
||||
/// A server enabled again shadows the fallbacks attached in its place, and a
|
||||
/// fallback that took over may replace a server attached earlier: they leave the
|
||||
/// open documents, and stop once no document uses them.
|
||||
/// A server enabled again shadows the fallbacks attached in its place: they
|
||||
/// leave the open documents, and stop once no document uses them.
|
||||
async fn detach_shadowed_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
@@ -3861,7 +3812,6 @@ impl LspManager {
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
|| configs.iter().any(|c| c.replaces.contains(&a.server.name))
|
||||
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
|
||||
})
|
||||
.map(|a| a.server.id)
|
||||
|
||||
@@ -110,17 +110,6 @@ pub fn is_decompiled(text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a `.class` stub is written in Java: its first `package` or `import` line ends with `;`.
|
||||
/// The Kotlin language server shows Java classes from jars as Java and Kotlin ones as Kotlin, under
|
||||
/// the same `.class` name.
|
||||
pub fn is_java_class_stub(text: &str) -> bool {
|
||||
text.lines()
|
||||
.take(200)
|
||||
.map(str::trim)
|
||||
.find(|line| line.starts_with("package ") || line.starts_with("import "))
|
||||
.is_some_and(|line| line.ends_with(';'))
|
||||
}
|
||||
|
||||
/// `scheme` of a URI (`jdt` for `jdt://…`).
|
||||
pub fn scheme(uri: &str) -> &str {
|
||||
uri.split_once(':').map(|(s, _)| s).unwrap_or("")
|
||||
@@ -216,17 +205,6 @@ mod tests {
|
||||
|
||||
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
|
||||
|
||||
#[test]
|
||||
fn class_stubs_in_java_or_kotlin() {
|
||||
let java = "// IntelliJ API Decompiler stub source generated from a class file\n\npackage com.boydti.fawe.util;\n\npublic class EditSessionBuilder {\n}\n";
|
||||
let kotlin = "// IntelliJ API Decompiler stub source generated from a class file\n// Implementation of methods is not available\n\npackage kotlin.collections\n\npublic fun <T> listOf(): List<T> { /* compiled code */ }\n";
|
||||
let imports_only = "import java.util.List;\n\npublic class A {}\n";
|
||||
assert!(is_java_class_stub(java));
|
||||
assert!(is_java_class_stub(imports_only));
|
||||
assert!(!is_java_class_stub(kotlin));
|
||||
assert!(!is_java_class_stub("public class A {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csharp_metadata_titles_and_kinds() {
|
||||
for (uri, expected) in [
|
||||
|
||||
@@ -264,7 +264,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
install_hint: Some(format!("install {name}")),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
replaces: Vec::new(),
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
@@ -297,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()));
|
||||
@@ -347,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 {
|
||||
@@ -2958,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())], &[]);
|
||||
@@ -3287,67 +3218,6 @@ async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
}
|
||||
|
||||
/// A server that `replaces` another (a PHP server with its own Laravel support and
|
||||
/// `laravel-lsp`) keeps it away while it serves, and only then.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_replaced_server_stays_away_while_its_replacement_serves() {
|
||||
let servers = [("main", incremental_caps()), ("alt", incremental_caps()), ("framework", incremental_caps())];
|
||||
let configure = |cfg: &mut LanguageServerConfig| match cfg.name.as_str() {
|
||||
"alt" => {
|
||||
cfg.fallback = true;
|
||||
cfg.replaces = vec!["framework".into()];
|
||||
}
|
||||
"framework" => cfg.require_root_marker = true,
|
||||
_ => {}
|
||||
};
|
||||
let h = harness_configured("mock", "mk", &servers, configure);
|
||||
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
{
|
||||
let disabled = disabled.clone();
|
||||
h.mgr.set_servers_hook(Box::new(move |_, defined| {
|
||||
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
|
||||
}));
|
||||
}
|
||||
std::fs::write(h.root.join("mock.toml"), "").unwrap();
|
||||
let (_, path) = h.open("a.mk", "x");
|
||||
let uri = uri_of(&path);
|
||||
|
||||
// The preferred server serves: the framework's server joins it, the alternative stays away.
|
||||
h.wait_status("main", ServerStatus::Ready).await;
|
||||
h.wait_status("framework", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("framework").docs.lock().contains_key(&uri)).await;
|
||||
assert!(h.statuses("alt").is_empty());
|
||||
|
||||
// The alternative takes over: the server it replaces closes the document and stops.
|
||||
disabled.lock().push("main".into());
|
||||
h.mgr.reconfigure().await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert!(!h.launcher.state("framework").notified("textDocument/didClose").is_empty());
|
||||
assert_eq!(h.statuses("framework").last(), Some(&ServerStatus::Stopped));
|
||||
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["alt"]);
|
||||
|
||||
// The preferred server again: the framework's server comes back with it.
|
||||
disabled.lock().clear();
|
||||
h.mgr.reconfigure().await;
|
||||
h.wait_status("main", ServerStatus::Ready).await;
|
||||
eventually(|| h.statuses("framework").last() == Some(&ServerStatus::Ready)).await;
|
||||
assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped));
|
||||
|
||||
// A preferred server that is not installed: the alternative replaces the framework's
|
||||
// server although that one attached first.
|
||||
let servers = [("ghost", json!({})), ("alt", incremental_caps()), ("framework", incremental_caps())];
|
||||
let h = harness_configured("mock", "mk", &servers, configure);
|
||||
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
|
||||
std::fs::write(h.root.join("mock.toml"), "").unwrap();
|
||||
let (_, path) = h.open("a.mk", "x");
|
||||
let uri = uri_of(&path);
|
||||
h.wait_status("ghost", ServerStatus::Missing).await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
eventually(|| !h.mgr.servers().iter().any(|s| s.name == "framework")).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
|
||||
@@ -419,7 +419,6 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
install_hint: Some("npm i -g nope-ls".into()),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
replaces: Vec::new(),
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
@@ -693,7 +692,6 @@ async fn typescript_language_server_end_to_end() {
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
replaces: Vec::new(),
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
@@ -747,7 +745,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
replaces: Vec::new(),
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 @@
|
||||
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
|
||||
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
|
||||
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
|
||||
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
|
||||
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
|
||||
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
|
||||
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
|
||||
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
|
||||
@@ -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 @@
|
||||
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
|
||||
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
|
||||
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
|
||||
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
|
||||
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
|
||||
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
|
||||
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
|
||||
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
|
||||
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
|
||||
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
|
||||
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
|
||||
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
|
||||
@@ -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
|
||||
Бінарний файл не відображається.
Loaded 100 of 656 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача