mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f00a29f9 | ||
|
|
834f9e172c | ||
|
|
45badba915 | ||
|
|
71728a473a | ||
|
|
2acd5cf479 | ||
|
|
85f4ffb67c | ||
|
|
4348dab26e | ||
|
|
38d4dd8c16 | ||
|
|
58af3c55a5 | ||
|
|
a8643366c8 | ||
|
|
22685815cc | ||
|
|
e6b88b2701 | ||
|
|
c076dedefc | ||
|
|
3764d963d7 | ||
|
|
4d2bcbbc26 | ||
|
|
56d9c8908c | ||
|
|
0bd0c4e03b | ||
|
|
209fc796ce | ||
|
|
e7e89e46c2 | ||
|
|
c87ab87417 | ||
|
|
a9ad1f1bbb | ||
|
|
4c8caca6a5 | ||
|
|
2ec2e75d40 | ||
|
|
04d5d9bd58 | ||
|
|
9a415e6875 | ||
|
|
6f83f8cad0 | ||
|
|
b971c8b391 | ||
|
|
c333990404 | ||
|
|
70b953c77a | ||
|
|
cb18b88531 | ||
|
|
401d7a4105 | ||
|
|
46bd1f6fb8 | ||
|
|
85f755591b | ||
|
|
06b7513210 | ||
|
|
09aa03bb86 | ||
|
|
317988cfa7 | ||
|
|
7db2e98269 | ||
|
|
fdc8bc2d4c | ||
|
|
497d4766d7 | ||
|
|
7e11d0729b | ||
|
|
2e78c42fac | ||
|
|
1d0449d430 | ||
|
|
73f7fa0016 | ||
|
|
f3dfb384c9 | ||
|
|
b53cfe8cc2 | ||
|
|
7d146ef1b7 | ||
|
|
4bde38ef9d | ||
|
|
c6212293e1 | ||
|
|
b8ba1256d2 | ||
|
|
6fc6bf598c | ||
|
|
95235220b5 | ||
|
|
620fa247ef | ||
|
|
52e5a0c4af | ||
|
|
4acb265b90 | ||
|
|
b284c37588 | ||
|
|
771575057e | ||
|
|
563d2a75d4 | ||
|
|
3a64bba254 | ||
|
|
f4b4a0fa72 | ||
|
|
50bd134b65 | ||
|
|
8840a1e58a |
No files matched your search
@@ -78,7 +78,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
with: { key: "${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
Generated
+1
@@ -4288,6 +4288,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
The IDE you know, rebuilt in Rust.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
@@ -19,8 +23,6 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<a href="#sponsor">Sponsor</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -70,17 +72,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Sponsor
|
||||
|
||||
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<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">
|
||||
你熟悉的 IDE,用 Rust 重新打造。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<a href="#赞助">赞助</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
|
||||
为核心的原生桌面应用里。
|
||||
|
||||
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
|
||||
提供 macOS、Windows 和 Linux 版本。
|
||||
|
||||
## 性能对比
|
||||
|
||||
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
|
||||
取 5 次运行的中位数:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| --------------------- | -------------- | ---------------------- |
|
||||
| 显示项目窗口 | **0.9 s** | 3.1 s |
|
||||
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
|
||||
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
|
||||
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
|
||||
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
|
||||
| 退出 | **0.3 s** | 5.9 s |
|
||||
|
||||
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
|
||||
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
|
||||
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
|
||||
|
||||
## 插件
|
||||
|
||||
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
|
||||
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
|
||||
|
||||
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
|
||||
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
|
||||
|
||||
## 社区
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
|
||||
- 微信群,扫码加入:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
|
||||
|
||||
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
|
||||
|
||||
## 赞助
|
||||
|
||||
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
|
||||
|
||||
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
|
||||
@@ -3,11 +3,13 @@
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
|
||||
//! `<home>/.codex/session_index.jsonl`).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -206,11 +208,39 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
|
||||
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
|
||||
/// so the last one wins.
|
||||
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
|
||||
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
|
||||
let mut names = HashMap::new();
|
||||
for line in std::io::BufReader::new(file).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
|
||||
let id = v.get("id").and_then(serde_json::Value::as_str);
|
||||
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
if let (Some(id), Some(name)) = (id, name) {
|
||||
names.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
|
||||
/// thread names, else by their first prompts.
|
||||
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
|
||||
let mut sessions: Vec<ExternalSession> =
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
|
||||
if !sessions.is_empty() {
|
||||
let names = codex_names(codex_home);
|
||||
for s in &mut sessions {
|
||||
if let Some(name) = names.get(&s.session_id) {
|
||||
s.title = short(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -282,6 +312,16 @@ mod tests {
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
|
||||
write(
|
||||
&tmp.path().join("session_index.jsonl"),
|
||||
&[
|
||||
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
|
||||
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
|
||||
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
|
||||
],
|
||||
);
|
||||
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1097,6 +1097,8 @@ impl AgentManager {
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
// Copying may have started the source's agent: it stops again once idle.
|
||||
source.release_when_idle();
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), task.clone());
|
||||
task.persist();
|
||||
@@ -1166,6 +1168,8 @@ impl AgentManager {
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
// Undoing may have started the agent: it stops again once idle.
|
||||
task.release_when_idle();
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,42 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Claude Code titles a session from the last 1000 characters of its text.
|
||||
const TITLE_SOURCE_CHARS: usize = 1000;
|
||||
/// Below this Claude Code gives no title without asking the model.
|
||||
const TITLE_SOURCE_MIN_CHARS: usize = 10;
|
||||
|
||||
/// The conversation's own text to title the session after: the prompts and the replies,
|
||||
/// without the files attached to the prompts. `None` while there is too little.
|
||||
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
for e in chain(entries, None) {
|
||||
if !(is_turn_start(e) || kind(e) == "assistant") {
|
||||
continue;
|
||||
}
|
||||
let blocks = match e.pointer("/message/content") {
|
||||
Some(Value::String(s)) => vec![s.as_str()],
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !t.starts_with("\n<context ref="))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for block in blocks {
|
||||
text.push_str(block);
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
let text = text.trim();
|
||||
let len = text.chars().count();
|
||||
if len < TITLE_SOURCE_MIN_CHARS {
|
||||
return None;
|
||||
}
|
||||
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -219,6 +255,29 @@ mod tests {
|
||||
assert_eq!(cut(&c, 1), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_come_from_prompts_and_replies() {
|
||||
let mut prompt = user("u1", None, "Fix the login form");
|
||||
prompt["message"]["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
|
||||
let entries = vec![
|
||||
prompt,
|
||||
assistant(
|
||||
"a1",
|
||||
"u1",
|
||||
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
|
||||
),
|
||||
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
|
||||
];
|
||||
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
|
||||
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
|
||||
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
|
||||
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_names() {
|
||||
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
|
||||
|
||||
@@ -191,6 +191,8 @@ struct State {
|
||||
limit_resets_ms: Option<f64>,
|
||||
terminal_only: Vec<String>,
|
||||
titled: bool,
|
||||
/// A title is being read or generated.
|
||||
titling: bool,
|
||||
}
|
||||
|
||||
struct Turn {
|
||||
@@ -365,6 +367,7 @@ impl Shim {
|
||||
limit_resets_ms: None,
|
||||
terminal_only: Vec::new(),
|
||||
titled: false,
|
||||
titling: false,
|
||||
}),
|
||||
cwd,
|
||||
starting: tokio::sync::Mutex::new(()),
|
||||
@@ -583,11 +586,12 @@ impl Shim {
|
||||
impl super::Shim for Shim {
|
||||
fn attach(&self, client: Peer) {
|
||||
let _ = self.client.set(client.clone());
|
||||
// The client went away: so do the processes.
|
||||
let weak = self.me.clone();
|
||||
// The client went away: so do the processes. Held until then: the connection drops
|
||||
// its handler (this shim) as it closes, and the sessions would go without stopping.
|
||||
let me = self.me.upgrade();
|
||||
tokio::spawn(async move {
|
||||
client.closed().await;
|
||||
if let Some(me) = weak.upgrade() {
|
||||
if let Some(me) = me {
|
||||
me.closed.store(true, Ordering::Release);
|
||||
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||
for s in sessions {
|
||||
@@ -598,6 +602,15 @@ impl super::Shim for Shim {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
/// A session dropped without being stopped (the shim went away) takes its process along.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.st.get_mut().proc.take() {
|
||||
p.kill.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn shim(&self) -> Option<Arc<Shim>> {
|
||||
self.shim.upgrade()
|
||||
@@ -1048,7 +1061,8 @@ impl Session {
|
||||
} else if matches!(st.start, Start::New { .. }) {
|
||||
st.start = Start::Resume { id: self.id.clone(), at: None };
|
||||
}
|
||||
read_title = !st.titled;
|
||||
read_title = !st.titled && !st.titling;
|
||||
st.titling |= read_title;
|
||||
if let Some(turn) = st.turn.as_ref()
|
||||
&& belongs(turn, &msg)
|
||||
{
|
||||
@@ -1073,13 +1087,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the title Claude Code gave the session, once it has one.
|
||||
/// Sends the session's title once it has one: the one in its transcript, or else one
|
||||
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
|
||||
/// Without a title the next turn tries again.
|
||||
fn read_title(self: &Arc<Self>) {
|
||||
let me = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Some(title) = chain_title(&me) else { return };
|
||||
me.st.lock().titled = true;
|
||||
if let Some(shim) = me.shim() {
|
||||
tokio::spawn(async move {
|
||||
let reader = me.clone();
|
||||
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
|
||||
let mut title = entries.as_deref().and_then(history::title);
|
||||
if title.is_none()
|
||||
&& let Some(source) = entries.as_deref().and_then(history::title_source)
|
||||
{
|
||||
title = me.generate_title(source).await;
|
||||
}
|
||||
{
|
||||
let mut st = me.st.lock();
|
||||
st.titling = false;
|
||||
if title.is_none() {
|
||||
return;
|
||||
}
|
||||
st.titled = true;
|
||||
}
|
||||
if let (Some(title), Some(shim)) = (title, me.shim()) {
|
||||
shim.notify(vec![Update {
|
||||
session: me.id.clone(),
|
||||
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
|
||||
@@ -1088,6 +1118,19 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
/// A title Claude Code generates from `source`, also written to the transcript.
|
||||
async fn generate_title(&self, source: String) -> Option<String> {
|
||||
let proc = self.st.lock().proc.clone()?;
|
||||
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
|
||||
match proc.control(request).await {
|
||||
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
|
||||
Err(e) => {
|
||||
tracing::debug!("generating a claude session title: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
|
||||
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
let request = msg.get("request").cloned().unwrap_or_default();
|
||||
@@ -1417,9 +1460,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
|
||||
/// The title in a session's transcript (or that of the session it continues).
|
||||
fn chain_title(session: &Session) -> Option<String> {
|
||||
history::title(&transcript_entries(session)?)
|
||||
}
|
||||
|
||||
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
|
||||
let shim = session.shim()?;
|
||||
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
|
||||
history::title(&history::read(&path).ok()?)
|
||||
history::read(&path).ok()
|
||||
}
|
||||
|
||||
fn start_json(start: &Start) -> Value {
|
||||
|
||||
@@ -311,6 +311,8 @@ struct Replay {
|
||||
sent: Arc<Mutex<Vec<Vec<Value>>>>,
|
||||
launched: Mutex<Vec<Vec<String>>>,
|
||||
errors: Arc<Mutex<Vec<String>>>,
|
||||
/// Each process's kill token.
|
||||
kills: Mutex<Vec<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Cli for Replay {
|
||||
@@ -331,6 +333,7 @@ impl Cli for Replay {
|
||||
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
|
||||
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
|
||||
let kill = CancellationToken::new();
|
||||
self.kills.lock().push(kill.clone());
|
||||
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
|
||||
sent.lock().push(Vec::new());
|
||||
tokio::spawn(async move {
|
||||
@@ -421,14 +424,18 @@ impl Replayed {
|
||||
}
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let processes = load_fixture(name);
|
||||
let cli = Arc::new(Replay {
|
||||
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
|
||||
Arc::new(Replay {
|
||||
processes: Mutex::new(processes.into()),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
kills: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let cli = replay_cli(load_fixture(name));
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h =
|
||||
@@ -461,6 +468,69 @@ async fn replay_simple() {
|
||||
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
|
||||
}
|
||||
|
||||
/// The app closing the connection (an idle task released, archived or deleted) stops the
|
||||
/// CLI processes of its sessions, idle ones included.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn closing_the_connection_stops_the_processes() {
|
||||
let r = replay("simple").await;
|
||||
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
|
||||
r.h.client.kill();
|
||||
let kills = r.cli.kills.lock().clone();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for k in kills {
|
||||
k.cancelled().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("every process was stopped");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sessions_without_a_title_get_one_generated() {
|
||||
let mut processes = load_fixture("simple");
|
||||
processes[0].extend([
|
||||
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
|
||||
"request": {"subtype": "generate_session_title"}})),
|
||||
Frame::Cli(json!({"type": "control_response",
|
||||
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
|
||||
]);
|
||||
let cli = replay_cli(processes);
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h = Harness::connect(
|
||||
cli.clone(),
|
||||
config.path().into(),
|
||||
config.path().join("pending"),
|
||||
cwd_path.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
let session = h.new_session().await;
|
||||
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
|
||||
let dir = config.path().join("projects").join(history::escape(&cwd_path));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
|
||||
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
|
||||
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
|
||||
|
||||
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
|
||||
let mut titles = Vec::new();
|
||||
for _ in 0..100 {
|
||||
titles = h.updates(&session, "session_info_update");
|
||||
if !titles.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
|
||||
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
|
||||
let sent = cli.sent.lock()[0].clone();
|
||||
let request =
|
||||
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
|
||||
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
|
||||
assert_eq!(request["request"]["persist"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn replay_multi_turn() {
|
||||
let r = replay("multi_turn").await;
|
||||
@@ -727,6 +797,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
@@ -770,6 +841,7 @@ async fn a_failed_turn_reports_its_failure() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
|
||||
@@ -28,6 +28,9 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// What a thread's name is asked for with, after its first turn.
|
||||
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
|
||||
language the user writes in. Reply with the title only, without quotes or a closing period.";
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -126,6 +129,10 @@ struct Thread {
|
||||
early_cancel: Option<Instant>,
|
||||
/// The latest agent message (a subagent's answer).
|
||||
answer: String,
|
||||
/// The thread has a name.
|
||||
named: bool,
|
||||
/// A name is being asked for.
|
||||
naming: bool,
|
||||
}
|
||||
|
||||
struct PendingPrompt {
|
||||
@@ -329,6 +336,7 @@ impl CodexShim {
|
||||
t.parent = None;
|
||||
t.settings = Some(settings);
|
||||
t.cwd = cwd;
|
||||
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
|
||||
(id, options)
|
||||
}
|
||||
|
||||
@@ -625,8 +633,9 @@ impl CodexShim {
|
||||
Some(translate::plan(params))
|
||||
}),
|
||||
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
|
||||
"thread/name/updated" => self.with_thread(thread, |_, _| {
|
||||
"thread/name/updated" => self.with_thread(thread, |t, _| {
|
||||
let title = params.get("threadName").and_then(Value::as_str)?;
|
||||
t.named = true;
|
||||
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
|
||||
}),
|
||||
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
|
||||
@@ -812,9 +821,10 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_completed(&self, thread: &str, turn: &Value) {
|
||||
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
|
||||
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
let (parent, answer) = {
|
||||
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
|
||||
let (parent, answer, name) = {
|
||||
let mut state = self.state.lock();
|
||||
let Some(t) = state.threads.get_mut(thread) else { return };
|
||||
if t.turn.as_deref().is_none_or(|r| r == id) {
|
||||
@@ -830,8 +840,15 @@ impl CodexShim {
|
||||
let _ = p.done.send((turn.clone(), error.clone()));
|
||||
}
|
||||
let answer = std::mem::take(&mut t.answer);
|
||||
(t.parent.clone(), (answer, error))
|
||||
let name =
|
||||
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
|
||||
t.naming |= name;
|
||||
(t.parent.clone(), (answer, error), name)
|
||||
};
|
||||
if name {
|
||||
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
|
||||
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
|
||||
}
|
||||
let Some(parent) = parent else { return };
|
||||
let (answer, error) = answer;
|
||||
let mut state_of = match turn.get("status").and_then(Value::as_str) {
|
||||
@@ -853,6 +870,28 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
|
||||
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
|
||||
async fn name_thread(&self, thread: &str, turn: &str) {
|
||||
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
|
||||
let named = match self.call("thread/sample", sample).await {
|
||||
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
|
||||
Some(name) => {
|
||||
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
|
||||
}
|
||||
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = &named {
|
||||
tracing::debug!("naming codex thread {thread}: {}", e.message);
|
||||
}
|
||||
if let Some(t) = self.state.lock().threads.get_mut(thread) {
|
||||
t.naming = false;
|
||||
t.named |= named.is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests from codex: approvals and questions, asked as ACP permission requests.
|
||||
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
|
||||
@@ -535,8 +535,10 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
// Older recordings predate skill discovery and thread naming; play an
|
||||
// unsupported server.
|
||||
let method = actual["method"].as_str().unwrap_or_default();
|
||||
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
@@ -574,7 +576,14 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
/// Runs `scenario` against its transcript; returns the ACP results, the client and
|
||||
/// the frames the shim sent.
|
||||
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
|
||||
let t = load(scenario.file());
|
||||
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
|
||||
(results, client, sent, meta)
|
||||
}
|
||||
|
||||
/// Both connections of a replay, which stay open while it is held.
|
||||
type CodexEnd = Box<dyn std::any::Any>;
|
||||
|
||||
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 20);
|
||||
let (tr, tw) = tokio::io::split(theirs);
|
||||
let client = Arc::new(Client::default());
|
||||
@@ -590,8 +599,8 @@ async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value)
|
||||
.await
|
||||
.expect("the scenario finished");
|
||||
// Codex stays up until the scenario is over.
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
(results, client, sent, meta)
|
||||
let (sent, read, write) = player.await.unwrap();
|
||||
(results, client, sent, meta, Box::new((acp, read, write)))
|
||||
}
|
||||
|
||||
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
|
||||
@@ -801,6 +810,42 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
|
||||
assert_eq!(info["title"], "Say hi");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_unnamed_thread_is_named_after_its_first_turn() {
|
||||
let mut t = load(Scenario::Simple.file());
|
||||
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
|
||||
let thread = "01a18300-0000-7000-8000-000000000001";
|
||||
t.frames.extend([
|
||||
(true, json!({"id": 90, "method": "thread/sample"})),
|
||||
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
|
||||
(true, json!({"id": 91, "method": "thread/name/set"})),
|
||||
(false, json!({"id": 91, "result": {}})),
|
||||
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
|
||||
]);
|
||||
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
|
||||
let session = session_of(&results, "new");
|
||||
let sample = &sent(&frames, "thread/sample")[0]["params"];
|
||||
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
|
||||
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
|
||||
let mut info = Vec::new();
|
||||
for _ in 0..100 {
|
||||
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
|
||||
if !info.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_names_are_the_first_line_of_the_reply() {
|
||||
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
|
||||
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
|
||||
assert_eq!(thread_name(" \n\"\" "), None);
|
||||
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn settings_apply_to_the_next_turn() {
|
||||
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
|
||||
|
||||
@@ -612,6 +612,16 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
|
||||
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
|
||||
}
|
||||
|
||||
/// A thread name from the model's reply to the naming prompt: its first line, without
|
||||
/// quotes or a closing period. `None` when nothing is left.
|
||||
pub(super) fn thread_name(reply: &str) -> Option<String> {
|
||||
const MAX_NAME: usize = 80;
|
||||
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
|
||||
let line = line.trim_end_matches(['.', '。']).trim();
|
||||
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
|
||||
}
|
||||
|
||||
/// How a failed turn is reported.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Failure {
|
||||
|
||||
@@ -2391,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
|
||||
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id));
|
||||
mgr.rollback(&task.id, 1).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
|
||||
mgr.fork(&task.id, None).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
|
||||
mgr.shutdown();
|
||||
|
||||
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
|
||||
|
||||
@@ -12,7 +12,8 @@ mod up_to_date;
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
|
||||
pub use launch_resources::process_launch_resources;
|
||||
pub use single::{
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
|
||||
single_file_target,
|
||||
};
|
||||
pub use up_to_date::{menu_up_to_date, record_menu_build};
|
||||
|
||||
|
||||
@@ -4,12 +4,21 @@
|
||||
//! walks the whole reactor; compiling one file only needs that file's module
|
||||
//! outputs plus its dependency classpath, which is much faster to assemble.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::process::Command as ProcCommand;
|
||||
|
||||
/// Output file of the `dependency:build-classpath` probe, relative to each module.
|
||||
const CP_FILE: &str = "target/ide-compile-cp.txt";
|
||||
|
||||
/// The resolved dependency classpath for one module and scope, tied to the same
|
||||
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CachedClasspath {
|
||||
fingerprint: LaunchFingerprint,
|
||||
classpath: String,
|
||||
}
|
||||
|
||||
/// A `.java` source and the module build that compiles it.
|
||||
pub struct SingleFileTarget {
|
||||
/// Absolute module directory.
|
||||
@@ -64,6 +73,28 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// The Maven reactor target for a launch module whose source file is not known yet.
|
||||
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
|
||||
let Some(m) = &model.maven else {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
};
|
||||
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
|
||||
let project = m
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| root.join(&p.dir) == module)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
if project.packaging == "pom" {
|
||||
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
|
||||
}
|
||||
let top = maven_reactor(m, &project.dir)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
|
||||
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
let home = env.get("JAVA_HOME").map(Path::new);
|
||||
@@ -78,6 +109,88 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
|
||||
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
|
||||
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
|
||||
classpath(root, model, target, "test", false)
|
||||
}
|
||||
|
||||
/// Runtime (or test) dependency classpath for a launch module.
|
||||
///
|
||||
/// The Java language server runs on its own JDK, so its Maven model can select a
|
||||
/// different JDK profile and dependency classifier than the project launch. Use the
|
||||
/// same Maven process that builds the module instead.
|
||||
pub fn module_classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cache: &Path,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let target = module_target(root, model, module, test)?;
|
||||
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
|
||||
let cache_path = classpath_cache_path(root, cache, &target.module, test);
|
||||
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
|
||||
&& let Ok(bytes) = fs::read(path)
|
||||
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
|
||||
&& cached.fingerprint == *fingerprint
|
||||
&& classpath_entries_exist(root, &cached.classpath)
|
||||
{
|
||||
return Ok(cached.classpath);
|
||||
}
|
||||
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
|
||||
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
|
||||
if let Err(error) = cache_classpath(&path, &cached) {
|
||||
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
|
||||
}
|
||||
}
|
||||
Ok(classpath)
|
||||
}
|
||||
|
||||
/// One cache file per module and scope. Mirroring the module path keeps nested
|
||||
/// modules and their different dependency scopes from overwriting each other.
|
||||
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
let relative = module.strip_prefix(root).ok()?;
|
||||
let mut path = cache.join("java-launch/classpaths");
|
||||
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
|
||||
path.push(if test { "test.json" } else { "runtime.json" });
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Best-effort cache persistence. A failed write only costs one later Maven
|
||||
/// probe, so the launch still succeeds even when the cache directory is unusable.
|
||||
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A cache entry stays valid while every file exists. Maven also emits reactor
|
||||
/// output directories for modules with no classes, which may legitimately never be
|
||||
/// created; the launch snapshot already supplies the module's classes in that case.
|
||||
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
|
||||
// Temp directories on macOS are commonly reached through `/var`, while Maven
|
||||
// reports the canonical `/private/var` path. Compare against both spellings.
|
||||
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
std::env::split_paths(classpath).all(|entry| {
|
||||
entry.exists()
|
||||
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
|
||||
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
|
||||
})
|
||||
}
|
||||
|
||||
fn classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
target: &SingleFileTarget,
|
||||
scope: &str,
|
||||
compile_first: bool,
|
||||
) -> IdeResult<String> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
// The module's own classes first: the file almost always uses classes next to it.
|
||||
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
|
||||
@@ -110,7 +223,14 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
|
||||
args.extend(["-pl", target.pl.as_str(), "-am"]);
|
||||
}
|
||||
let output_arg = format!("-Dmdep.outputFile={probe}");
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
|
||||
let scope_arg = format!("-Dmdep.includeScope={scope}");
|
||||
// A launch build may only have reached `compile`. Running `compile` in this
|
||||
// same Maven session lets the dependency plugin resolve Reactor siblings from
|
||||
// their target/classes without paying for package-phase plugins.
|
||||
if compile_first {
|
||||
args.push("compile");
|
||||
}
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
|
||||
let top_dir = subdir(root, &target.top.dir);
|
||||
let status = ProcCommand::new(&target.top.command)
|
||||
.args(&args)
|
||||
@@ -119,8 +239,10 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
|
||||
.output()
|
||||
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
|
||||
if !status.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&status.stdout);
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
// Maven reports reactor/dependency failures on stdout, not only stderr.
|
||||
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
|
||||
}
|
||||
@@ -305,7 +427,7 @@ mod tests {
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
@@ -327,5 +449,90 @@ mod tests {
|
||||
assert!(cp.contains("/repo/a.jar"), "{cp}");
|
||||
assert!(cp.contains("/repo/b.jar"), "{cp}");
|
||||
assert!(cp.split(separator).count() >= 3, "{cp}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
|
||||
}
|
||||
|
||||
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_module_classpath_uses_the_matching_dependency_scope() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let model = crate::load(root, &root.join("cache"), None);
|
||||
|
||||
let cache = root.join("cache");
|
||||
let runtime =
|
||||
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "compile"), "{args}");
|
||||
assert!(!args.lines().any(|a| a == "package"), "{args}");
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
|
||||
|
||||
let test =
|
||||
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(test.contains("/repo/test.jar"), "{test}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
|
||||
}
|
||||
|
||||
/// The launch classpath follows the Maven runner JDK and is reused until that
|
||||
/// build input changes; the probe does not run packaging plugins.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_classpath_cache_follows_the_runner_jdk() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
|
||||
// the fake probe faithful so the cache's existence check sees the output.
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = crate::load(root, &root.join("cache"), None);
|
||||
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
|
||||
let home = root.join(name);
|
||||
fs::create_dir_all(home.join("bin")).unwrap();
|
||||
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
|
||||
fs::write(home.join("bin/java"), "").unwrap();
|
||||
}
|
||||
let cache = root.join("classpath-cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let jdk21 = root.join("jdk21");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
|
||||
}
|
||||
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
|
||||
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
|
||||
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
|
||||
|
||||
let jdk8 = root.join("jdk8");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
|
||||
}
|
||||
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
|
||||
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
|
||||
}
|
||||
}
|
||||
@@ -712,18 +712,27 @@ pub(crate) fn launch_inputs(
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
// A source root under Maven's build directory is generated by the build itself.
|
||||
// Skip it without resolving the property: `<build><directory>` may be custom, and
|
||||
// hashing its rewritten files would make every launch look changed.
|
||||
if !(input && el.text.contains("${project.build.directory}")) {
|
||||
// Other properties can be overridden by -D, profiles or settings. Without
|
||||
// Maven's effective model, leave them unresolved to conservatively disable reuse.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
let path = dir.join(value);
|
||||
// Conventional generated sources under target are also build outputs.
|
||||
if !(input && path.starts_with(dir.join("target"))) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
@@ -2140,6 +2149,52 @@ mod tests {
|
||||
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
|
||||
<packaging>pom</packaging><modules><module>app</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><build><plugins><plugin>
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
|
||||
<executions><execution><configuration><sources>
|
||||
<source>${project.build.directory}/generated-sources/java</source>
|
||||
</sources></configuration></execution></executions>
|
||||
</plugin></plugins></build></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
|
||||
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
|
||||
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
|
||||
<build><directory>${project.basedir}/custom-build</directory></build>
|
||||
<profiles><profile><id>generated</id><activation><file>
|
||||
<exists>${project.build.directory}/enable-profile</exists>
|
||||
</file></activation></profile></profiles></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
assert!(launch_inputs(root, &projects, None).is_none());
|
||||
}
|
||||
|
||||
fn write(dir: &Path, rel: &str, text: &str) {
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
|
||||
@@ -270,6 +270,13 @@ fn run(
|
||||
|
||||
/// The first error lines of a failed build, for the tool window.
|
||||
pub(crate) fn error_summary(text: &str) -> String {
|
||||
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
|
||||
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
|
||||
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
|
||||
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
|
||||
in Settings › Build, Execution, Deployment › Gradle."
|
||||
.to_owned();
|
||||
}
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
@@ -909,6 +916,8 @@ mod tests {
|
||||
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
|
||||
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -270,6 +270,8 @@ pub struct SmartKeysSettings {
|
||||
pub pair_quotes: bool,
|
||||
pub surround_selection: bool,
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
|
||||
pub simultaneous_tag_editing: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
@@ -289,6 +291,7 @@ impl Default for SmartKeysSettings {
|
||||
pair_quotes: true,
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
simultaneous_tag_editing: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
|
||||
@@ -77,6 +77,9 @@ pub enum IdeEvent {
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
|
||||
/// not announced as one.
|
||||
by_agent: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
|
||||
@@ -77,6 +77,8 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
|
||||
@@ -300,11 +300,30 @@ pub struct McpServerSettings {
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
/// port forwards keep working after a restart.
|
||||
pub fixed_port: bool,
|
||||
/// The port of [`Self::fixed_port`].
|
||||
pub port: u16,
|
||||
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
|
||||
/// make a new one each run.
|
||||
pub fixed_token: bool,
|
||||
}
|
||||
|
||||
/// The default of [`McpServerSettings::port`].
|
||||
pub const DEFAULT_MCP_PORT: u16 = 26371;
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
|
||||
Self {
|
||||
enabled: false,
|
||||
run_without_confirmation: false,
|
||||
agent_tab_limit: 1,
|
||||
agent_archive_limit: 999,
|
||||
fixed_port: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixed_token: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ tracing.workspace = true
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
+195
-12
@@ -1,6 +1,7 @@
|
||||
//! Attach to Process: the current user's processes, grouped by the debugger that can
|
||||
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
|
||||
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
|
||||
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
|
||||
//! natively (LLDB).
|
||||
|
||||
use crate::adapter;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -14,6 +15,7 @@ pub enum AttachKind {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Dotnet,
|
||||
Go,
|
||||
Native,
|
||||
}
|
||||
@@ -95,13 +97,29 @@ fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
|
||||
pub fn is_dotnet(exe: &str) -> bool {
|
||||
let path = Path::new(exe);
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
|
||||
if stem == "dotnet" {
|
||||
return true;
|
||||
}
|
||||
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
|
||||
}
|
||||
|
||||
fn is_python(name: &str) -> bool {
|
||||
let n = name.strip_suffix(".exe").unwrap_or(name);
|
||||
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
}
|
||||
|
||||
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
|
||||
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
|
||||
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
|
||||
/// interpreter claims.
|
||||
pub fn classify(
|
||||
p: &RawProcess,
|
||||
is_dotnet: impl Fn(&str) -> bool,
|
||||
is_go: impl Fn(&str) -> bool,
|
||||
native_adapter: &str,
|
||||
) -> Option<AttachableProcess> {
|
||||
let name = file_name(&p.exe).to_owned();
|
||||
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
|
||||
let (host, port) = match addr {
|
||||
@@ -129,6 +147,9 @@ pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &s
|
||||
if is_python(&name) {
|
||||
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
|
||||
}
|
||||
if is_dotnet(&p.exe) {
|
||||
return make(AttachKind::Dotnet, "netcoredbg", None);
|
||||
}
|
||||
if is_go(&p.exe) {
|
||||
return make(AttachKind::Go, "delve", None);
|
||||
}
|
||||
@@ -143,7 +164,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
|
||||
pub fn is_go_binary(exe: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(exe) else { return false };
|
||||
@@ -165,6 +186,17 @@ pub fn is_go_binary(exe: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
|
||||
/// system locations skipped on Unix.
|
||||
fn is_windows_system(exe: &str) -> bool {
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
lower.get(1..).is_some_and(|rest| {
|
||||
rest.starts_with(":\\windows\\")
|
||||
|| rest.starts_with(":\\program files\\")
|
||||
|| rest.starts_with(":\\program files (x86)\\")
|
||||
})
|
||||
}
|
||||
|
||||
/// The current user's processes (without this one).
|
||||
#[cfg(unix)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
@@ -181,7 +213,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
let (pid, rest) = line.split_once(char::is_whitespace)?;
|
||||
Some((pid.parse().ok()?, rest.trim().to_owned()))
|
||||
};
|
||||
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
|
||||
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
|
||||
let exes: HashMap<u32, String> =
|
||||
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
|
||||
let me = std::process::id();
|
||||
@@ -190,9 +222,8 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.filter_map(split)
|
||||
.filter(|(pid, _)| *pid != me)
|
||||
.map(|(pid, args)| {
|
||||
let exe = exes
|
||||
.get(&pid)
|
||||
.cloned()
|
||||
let exe = exe_path(pid)
|
||||
.or_else(|| exes.get(&pid).cloned())
|
||||
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
|
||||
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
|
||||
RawProcess { pid, exe, args }
|
||||
@@ -200,9 +231,134 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
/// The processes this user may open (without this one): system and other users'
|
||||
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
|
||||
#[cfg(windows)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
|
||||
windows_processes::list()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_processes {
|
||||
use super::RawProcess;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
|
||||
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
|
||||
};
|
||||
|
||||
pub fn list() -> IdeResult<Vec<RawProcess>> {
|
||||
let me = std::process::id();
|
||||
Ok(pids()?
|
||||
.into_iter()
|
||||
.filter(|&pid| pid != 0 && pid != me)
|
||||
.filter_map(|pid| {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned here.
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
let exe = image_path(&process)?;
|
||||
let args = command_line(&process).unwrap_or_else(|| exe.clone());
|
||||
Some(RawProcess { pid, exe, args })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn pids() -> IdeResult<Vec<u32>> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"cannot list processes: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
let snapshot = OwnedHandle::from_raw_handle(snapshot);
|
||||
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
|
||||
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
|
||||
while more != 0 {
|
||||
out.push(entry.th32ProcessID);
|
||||
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn image_path(process: &OwnedHandle) -> Option<String> {
|
||||
let mut buf = vec![0u16; 32 * 1024];
|
||||
let mut len = buf.len() as u32;
|
||||
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
|
||||
let ok = unsafe {
|
||||
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
|
||||
};
|
||||
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
|
||||
}
|
||||
|
||||
/// The command line (Windows 8.1+), read with the same limited query access.
|
||||
fn command_line(process: &OwnedHandle) -> Option<String> {
|
||||
let mut size = 0u32;
|
||||
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
|
||||
unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if (size as usize) < size_of::<UNICODE_STRING>() {
|
||||
return None;
|
||||
}
|
||||
// u64 elements keep the UNICODE_STRING header aligned.
|
||||
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
|
||||
// SAFETY: a buffer of at least `size` bytes.
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
buf.as_mut_ptr().cast(),
|
||||
size,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if status < 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
|
||||
let text = unsafe {
|
||||
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
|
||||
if s.Buffer.is_null() {
|
||||
return None;
|
||||
}
|
||||
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
|
||||
};
|
||||
Some(String::from_utf16_lossy(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// The full executable path: `comm` keeps a relative path a process was started with.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn exe_path(pid: u32) -> Option<String> {
|
||||
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
|
||||
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
|
||||
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn exe_path(_pid: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Node's default inspector port, which `SIGUSR1` opens.
|
||||
@@ -244,7 +400,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
|
||||
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_map(|p| classify(p, is_go_binary, native)).collect();
|
||||
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
|
||||
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
@@ -258,7 +414,8 @@ mod tests {
|
||||
}
|
||||
|
||||
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
|
||||
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
|
||||
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
|
||||
.map(|p| (p.kind, p.port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -292,11 +449,37 @@ mod tests {
|
||||
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
|
||||
Some((AttachKind::Python, None))
|
||||
);
|
||||
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
|
||||
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
|
||||
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
|
||||
assert!(!is_python("pythonista"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dotnet_hosts_and_apphosts() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let app = dir.join("Api");
|
||||
std::fs::write(&app, b"").unwrap();
|
||||
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
|
||||
let tool = dir.join("tool");
|
||||
std::fs::write(&tool, b"").unwrap();
|
||||
assert!(is_dotnet(app.to_str().unwrap()));
|
||||
assert!(!is_dotnet(tool.to_str().unwrap()));
|
||||
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
|
||||
assert!(is_dotnet("dotnet"));
|
||||
assert!(!is_dotnet("Api"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_windows_system_programs() {
|
||||
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
|
||||
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
|
||||
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
|
||||
assert!(!is_windows_system("/usr/bin/true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_go_binaries_by_buildinfo() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
|
||||
|
||||
@@ -557,6 +557,17 @@ async fn attach_to_processes() {
|
||||
extra: |pid, _| serde_json::json!({"pid": pid}),
|
||||
},
|
||||
];
|
||||
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
|
||||
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
|
||||
if dotnet_app.is_file() {
|
||||
cases.push(AttachCase {
|
||||
kind: attach::AttachKind::Dotnet,
|
||||
command: vec![s(&dotnet_app)],
|
||||
source: dir.join("dotnet/Program.cs"),
|
||||
line: 1,
|
||||
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
|
||||
});
|
||||
}
|
||||
if java_server.is_some() {
|
||||
cases.insert(
|
||||
0,
|
||||
|
||||
@@ -180,6 +180,7 @@ impl Host {
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
ops::agent::move_user_mcp_values(&host);
|
||||
host
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
|
||||
return Ok(());
|
||||
}
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
|
||||
crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
|
||||
}
|
||||
|
||||
/// The open document of `path`, or a transient one (`true`: close it after use).
|
||||
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
|
||||
preconditions: Vec::new(),
|
||||
};
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
|
||||
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
|
||||
}
|
||||
FileOp::Update { path: first, move_to, hunks, or } => {
|
||||
// A plain diff's other names count when its `+++` path does not exist.
|
||||
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
|
||||
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
|
||||
}
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
|
||||
if dirty {
|
||||
notes.push(format!("{shown}: patched the unsaved editor buffer"));
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
|
||||
_ => None,
|
||||
});
|
||||
let (h, w) = (host.clone(), window.to_owned());
|
||||
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
|
||||
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
|
||||
if let Err(e) = written {
|
||||
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
|
||||
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! window they run on the host, next to the project they work on.
|
||||
use super::{Channel, Emit};
|
||||
use crate::Host;
|
||||
use crate::agent_secrets::{AgentSecrets, Kept};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
@@ -342,8 +343,77 @@ pub async fn accept_changes(
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
|
||||
/// servers keep their values in the project's file, where the project shares them.
|
||||
const USER_MCP_VALUES: &str = "mcp/user/";
|
||||
|
||||
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
|
||||
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
|
||||
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
|
||||
let mut values = Vec::new();
|
||||
for (server, cfg) in map.iter_mut() {
|
||||
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
|
||||
for (name, value) in entries.iter_mut() {
|
||||
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn has_mcp_values(map: &McpServerMap) -> bool {
|
||||
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
|
||||
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty()
|
||||
&& let Some(saved) = secrets.get(&key)
|
||||
{
|
||||
*value = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the values of `map` and empties them in it; values of servers and names no
|
||||
/// longer in it are forgotten.
|
||||
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
|
||||
let mut kept = Kept::Store;
|
||||
let mut current = HashSet::new();
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty() {
|
||||
secrets.delete(&key);
|
||||
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
|
||||
kept = Kept::LocalFile;
|
||||
}
|
||||
value.clear();
|
||||
current.insert(key);
|
||||
}
|
||||
for key in secrets.keys(USER_MCP_VALUES) {
|
||||
if !current.contains(&key) {
|
||||
secrets.delete(&key);
|
||||
}
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
|
||||
pub fn move_user_mcp_values(host: &Host) {
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
if !has_mcp_values(&user) {
|
||||
return;
|
||||
}
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
|
||||
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
let user = host.global_settings.effective().agents.mcp_servers;
|
||||
move_user_mcp_values(host);
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
let project = host.project(window).ok().filter(|p| !p.light);
|
||||
let workspace = project
|
||||
.as_ref()
|
||||
@@ -358,10 +428,15 @@ fn persist_mcp_map(
|
||||
scope: McpScope,
|
||||
map: &McpServerMap,
|
||||
) -> IdeResult<()> {
|
||||
let mut map = map.clone();
|
||||
let kept = match scope {
|
||||
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
|
||||
McpScope::Workspace => Kept::Store,
|
||||
};
|
||||
let value = if map.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
};
|
||||
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
|
||||
match scope {
|
||||
@@ -370,6 +445,9 @@ fn persist_mcp_map(
|
||||
for p in host.projects() {
|
||||
p.settings.reload();
|
||||
p.sink.emit(event.clone());
|
||||
if kept == Kept::LocalFile {
|
||||
host.agent_secrets.notify_kept_locally(&p.sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
@@ -418,15 +496,17 @@ fn listed(
|
||||
ide_agent::mcp::servers_from_settings(user, &workspace)
|
||||
}
|
||||
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
|
||||
/// user turned on.
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
|
||||
/// the project's the user turned on.
|
||||
pub(crate) fn session_mcp_servers(
|
||||
settings: &ide_workspace::SettingsStore,
|
||||
approvals: &crate::trust::McpServerApprovals,
|
||||
secrets: &AgentSecrets,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<McpServer> {
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
fill_mcp_values(secrets, &mut user);
|
||||
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
|
||||
ide_agent::mcp::servers_from_settings(&user, &workspace)
|
||||
}
|
||||
@@ -593,6 +673,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
@@ -605,6 +686,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
|
||||
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
|
||||
let secrets = AgentSecrets::new(&tmp.path().join("data"));
|
||||
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
|
||||
servers
|
||||
.into_iter()
|
||||
@@ -613,13 +695,27 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
on(session_mcp_servers(&settings, &approvals, &root)),
|
||||
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
|
||||
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
|
||||
);
|
||||
|
||||
// A user server's saved values reach the session; the file keeps only the names.
|
||||
std::fs::write(
|
||||
config.join("settings.toml"),
|
||||
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
|
||||
settings.reload();
|
||||
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
|
||||
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
|
||||
assert_eq!(keyed.env[0].value, "saved-user");
|
||||
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
|
||||
settings.reload();
|
||||
|
||||
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
|
||||
approvals.approve(&root, "extra", &extra).unwrap();
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
|
||||
McpScope::Workspace,
|
||||
"extra".to_owned(),
|
||||
"extra".to_owned()
|
||||
|
||||
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -330,21 +331,61 @@ pub async fn build_sync(
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let lsp = project.lsp.clone();
|
||||
let documents = project.documents.clone();
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
let first = updates.first().cloned();
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
let changed = {
|
||||
let lsp = lsp.clone();
|
||||
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
|
||||
};
|
||||
if let Some(file) = first
|
||||
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
|
||||
{
|
||||
tracing::debug!("openidea.java.refreshArchives: {e}");
|
||||
}
|
||||
refresh_library_classes(&lsp, &documents).await;
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
|
||||
/// again keeps its path) show the new contents, like a reopened one.
|
||||
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
|
||||
let changed = {
|
||||
let (lsp, documents) = (lsp.clone(), documents.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
|
||||
.filter(|(_, uri)| lsp.library_class_changed(uri))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (doc, uri) in changed {
|
||||
match lsp.virtual_document(&uri).await {
|
||||
Ok(vd) => {
|
||||
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
|
||||
tracing::debug!("refreshing {uri}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
@@ -985,6 +1026,38 @@ mod build_tool_settings_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing Project JDK updates the next Maven build without reopening the project.
|
||||
#[test]
|
||||
fn changing_project_jdk_updates_the_next_maven_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
|
||||
let home = root.join(name);
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
|
||||
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
|
||||
}
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.maven.runner_jre.clear();
|
||||
for name in ["jdk8", "jdk21"] {
|
||||
settings.languages.java.jdk = name.into();
|
||||
let expected = root.join(name);
|
||||
let model = load(&root, &root.join("cache"), &settings);
|
||||
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
|
||||
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
|
||||
assert!(!plan.steps.is_empty());
|
||||
for step in plan.steps {
|
||||
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1019,7 +1092,6 @@ mod maven_completion_tests {
|
||||
use ide_build::maven::completion::PomCompletionKind;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_workspace::dependencies::DependencyVersionSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cache answers the test plants before completing, so the op's Central
|
||||
/// lookups hit the session cache and the network is never reached.
|
||||
|
||||
@@ -188,8 +188,14 @@ pub async fn remote_download_into(
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
/// `password`: an FTP password typed in the settings but not applied yet.
|
||||
pub async fn deploy_test_connection(
|
||||
state: &Host,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
state.remote.test_connection_with(&connection, &path, password).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
@@ -231,6 +237,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
|
||||
});
|
||||
}
|
||||
@@ -96,18 +96,19 @@ async fn resolve_launch(
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let built = if delegated {
|
||||
let policy = delegated_policy(policy, prefer_maven, info.error_count);
|
||||
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
cache::BuildOptions { test, policy },
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
|
||||
local_repo.clone(),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
|
||||
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
|
||||
copies
|
||||
} else if external {
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
@@ -123,10 +124,22 @@ async fn resolve_launch(
|
||||
Vec::new()
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
if delegated {
|
||||
// m2e runs inside jdtls on the server JDK and can resolve a different
|
||||
// Maven profile/classifier than the module build. Ask the same Maven
|
||||
// process that built the module for its runtime/test dependencies.
|
||||
let maven_cp = compile::module_classpath(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
local_repo,
|
||||
&project.build_cache_dir(),
|
||||
&cancel,
|
||||
);
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
|
||||
} else {
|
||||
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
|
||||
@@ -191,21 +204,18 @@ async fn resolve_launch(
|
||||
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
|
||||
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
|
||||
/// compiler, so a launch JDT reports errors for recompiles everything.
|
||||
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
|
||||
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
|
||||
BuildPolicy::Rebuild
|
||||
} else {
|
||||
policy
|
||||
}
|
||||
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
|
||||
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
JavaBuildStatus::Failed
|
||||
} else if clean {
|
||||
// A clean external build recompiled every source; JDT's diagnostics still describe
|
||||
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
|
||||
} else if authoritative {
|
||||
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
|
||||
// may describe an older output or its own classpath, so they must not mark this launch
|
||||
// as failed after Maven has produced usable classes.
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
JavaBuildStatus::Succeeded
|
||||
@@ -286,6 +296,44 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
|
||||
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
|
||||
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
|
||||
/// and leave the reason in the run console.
|
||||
fn merge_maven_launch_classpath(
|
||||
class_paths: &mut Vec<String>,
|
||||
info: &mut JavaLaunchInfo,
|
||||
classpath: IdeResult<String>,
|
||||
) -> IdeResult<()> {
|
||||
match classpath {
|
||||
Ok(classpath) => {
|
||||
append_unique_class_paths(
|
||||
class_paths,
|
||||
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
|
||||
Err(error) => {
|
||||
info.hints.push(format!(
|
||||
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
|
||||
error.message
|
||||
));
|
||||
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append entries without changing their precedence; `same_entry` also catches
|
||||
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
|
||||
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
|
||||
for entry in entries {
|
||||
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
|
||||
class_paths.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
use_snapshot_on(info, built, snapshot, cfg!(windows))
|
||||
}
|
||||
@@ -410,14 +458,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
|
||||
assert_eq!(
|
||||
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
|
||||
BuildPolicy::MakeAndContinueOnErrors
|
||||
);
|
||||
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
|
||||
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
|
||||
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
|
||||
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
|
||||
let mut class_paths = vec!["snapshot.jar".to_owned()];
|
||||
let mut info =
|
||||
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
|
||||
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
|
||||
|
||||
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
|
||||
.unwrap();
|
||||
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
|
||||
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
|
||||
let mut class_paths = vec!["snapshot.jar".to_owned()];
|
||||
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
|
||||
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(class_paths, ["snapshot.jar"]);
|
||||
assert!(info.hints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -461,7 +531,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_external_build_clears_stale_jdt_errors() {
|
||||
fn authoritative_external_result_clears_stale_jdt_errors() {
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 7,
|
||||
errors: vec![ide_lsp::jdtls::JavaCompileError {
|
||||
|
||||
@@ -14,6 +14,10 @@ pub(super) struct Outputs {
|
||||
pub originals: Vec<PathBuf>,
|
||||
#[serde(skip)]
|
||||
pub failure: Option<String>,
|
||||
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
|
||||
/// verified snapshot. It is runtime-only because reuse is determined per launch.
|
||||
#[serde(skip)]
|
||||
pub authoritative: bool,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
@@ -42,6 +46,9 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
pub(super) struct BuildOptions {
|
||||
pub test: bool,
|
||||
pub policy: BuildPolicy,
|
||||
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
|
||||
/// snapshot from being reused on the next launch.
|
||||
pub rebuild_on_jdt_errors: bool,
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
@@ -75,7 +82,7 @@ impl Session {
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let BuildOptions { test, policy } = options;
|
||||
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
@@ -115,8 +122,14 @@ impl Session {
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
return Ok(previous.unwrap());
|
||||
let mut outputs = previous.unwrap();
|
||||
outputs.authoritative = true;
|
||||
return Ok(outputs);
|
||||
}
|
||||
// JDT can report errors from its own classpath while the last Maven snapshot is
|
||||
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
|
||||
// clean rebuild that replaces JDT's error-stub classes.
|
||||
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
|
||||
// Retain a non-reusable record even on failure, so removing the final mixed
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
@@ -156,7 +169,13 @@ impl Session {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
|
||||
let mut outputs = Outputs {
|
||||
fingerprint: None,
|
||||
snapshot,
|
||||
originals,
|
||||
failure: built.failure,
|
||||
authoritative: policy == BuildPolicy::Rebuild,
|
||||
};
|
||||
if outputs.failure.is_none() && before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
@@ -334,6 +353,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(root.join("cache"));
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(!first.authoritative);
|
||||
|
||||
let reused = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reused.snapshot, first.snapshot);
|
||||
assert!(reused.authoritative);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
|
||||
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let rebuilt = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot);
|
||||
assert!(rebuilt.authoritative);
|
||||
let calls = fs::read_to_string(root.join("calls")).unwrap();
|
||||
assert_eq!(calls.lines().count(), 2);
|
||||
assert!(calls.lines().last().unwrap().contains("clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -483,7 +544,13 @@ mod tests {
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
|
||||
let outputs = Outputs {
|
||||
fingerprint: None,
|
||||
snapshot: retained.clone(),
|
||||
originals: Vec::new(),
|
||||
failure: None,
|
||||
authoritative: false,
|
||||
};
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
|
||||
@@ -1483,23 +1483,38 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
|
||||
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
record_before_multi_file_edit(&project, &edit);
|
||||
apply_edit(&project.documents, &edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
|
||||
/// not announce it as a refactoring.
|
||||
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
record_before_multi_file_edit(&project, &edit);
|
||||
project.documents.apply_agent_workspace_edit(&edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Multi-file edits leave a Local History point to go back to.
|
||||
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
|
||||
@@ -675,9 +675,10 @@ impl Host {
|
||||
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
|
||||
}
|
||||
let approvals = self.mcp_approvals.clone();
|
||||
let secrets = self.agent_secrets.clone();
|
||||
let root = root.to_path_buf();
|
||||
agents.set_mcp_servers_hook(Box::new(move || {
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
|
||||
}));
|
||||
(db, http_history, agents)
|
||||
}
|
||||
|
||||
@@ -231,7 +231,11 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
|
||||
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
|
||||
assert!(*by_agent, "an MCP edit is the agent's");
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Values of the user's MCP servers' environment variables and HTTP headers live in the
|
||||
//! credential store; `settings.toml` keeps only their names. A project's own servers keep
|
||||
//! their values in the project's file, where the project shares them.
|
||||
|
||||
use ide_agent::{McpKeyValue, McpScope, McpServer, McpServerInput};
|
||||
use ide_core::NullSink;
|
||||
use ide_core::settings::McpTransport;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VALUE: &str = "mcp-value-0f3c9a";
|
||||
|
||||
fn host(dir: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: dir.join("config"),
|
||||
data_dir: dir.join("data"),
|
||||
local_data_dir: dir.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,
|
||||
}))
|
||||
}
|
||||
|
||||
fn project(tmp: &tempfile::TempDir) -> PathBuf {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn open(host: &Host, root: &Path) {
|
||||
host.trust.decide(root, true, false).unwrap();
|
||||
host.open_project("main", root).unwrap();
|
||||
}
|
||||
|
||||
fn header(servers: &[McpServer], id: &str) -> String {
|
||||
let server = servers.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} in {servers:?}"));
|
||||
server.headers.iter().find(|h| h.name == "Authorization").map(|h| h.value.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn input(name: &str, scope: McpScope) -> McpServerInput {
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: name.into(),
|
||||
scope,
|
||||
transport: McpTransport::Http,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: vec![],
|
||||
env: vec![],
|
||||
url: Some("https://mcp.example/v1".into()),
|
||||
headers: vec![McpKeyValue { name: "Authorization".into(), value: format!("Bearer {VALUE}") }],
|
||||
agents: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_WITH_VALUE: &str = r#"
|
||||
[agents.mcpServers.remote]
|
||||
transport = "http"
|
||||
enabled = true
|
||||
url = "https://mcp.example/v1"
|
||||
headers = { Authorization = "Bearer mcp-value-0f3c9a" }
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_server_values_stay_out_of_the_user_settings() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
for (name, scope) in [("user-server", McpScope::User), ("repo-server", McpScope::Workspace)] {
|
||||
ide_host::ops::agent::mcp_save(&host, "main", input(name, scope)).unwrap();
|
||||
}
|
||||
host.close_project("main");
|
||||
|
||||
// Only the project's own file holds a value; nothing in the user's config or data does.
|
||||
let found = files_containing(tmp.path(), VALUE);
|
||||
assert!(found.len() == 1 && found[0].ends_with(Path::new(".openidea/settings.toml")), "{found:?}");
|
||||
|
||||
let host = self::host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::user-server"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(header(&servers, "workspace::repo-server"), format!("Bearer {VALUE}"));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn values_in_user_settings_move_to_the_credential_store() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
|
||||
let saved = SERVER_WITH_VALUE.replace("Authorization =", "X-Saved = \"\", Authorization =");
|
||||
std::fs::write(tmp.path().join("config/settings.toml"), saved).unwrap();
|
||||
ide_host::agent_secrets::AgentSecrets::new(&tmp.path().join("data"))
|
||||
.set("mcp/user/remote/header/X-Saved", "saved-earlier")
|
||||
.unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::remote"), format!("Bearer {VALUE}"));
|
||||
let remote = servers.iter().find(|s| s.id == "user::remote").unwrap();
|
||||
assert!(remote.headers.iter().any(|h| h.name == "X-Saved" && h.value == "saved-earlier"), "{remote:?}");
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_projects_own_values_stay_in_its_file() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let file = root.join(".openidea/settings.toml");
|
||||
std::fs::write(&file, SERVER_WITH_VALUE).unwrap();
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), SERVER_WITH_VALUE, "reading leaves the project file alone");
|
||||
|
||||
// Saving the servers keeps the project's values in the project's file for everyone else.
|
||||
let servers = ide_host::ops::agent::mcp_set_enabled(&host, "main", "workspace::remote".into(), false).unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains(VALUE));
|
||||
assert_eq!(files_containing(&tmp.path().join("data"), VALUE), Vec::<PathBuf>::new());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -2609,6 +2609,82 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
assert!(err.contains("Restricted Mode"), "{err}");
|
||||
}
|
||||
|
||||
/// Git Toolbox in a workspace window whose container folder is not a
|
||||
/// repository (every member is its own clone): the status bar follows the
|
||||
/// focused file's repository, and the scheduled fetch covers every member.
|
||||
#[test]
|
||||
fn official_git_toolbox_follows_members_in_a_workspace() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A remote with `main`, cloned by member `a` (at `main`) and member `b`
|
||||
// (on its own branch, to tell the two apart in the status bar).
|
||||
let remote = tmp.path().join("remote.git");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
git(&remote, &["init", "--bare", "-b", "main"]);
|
||||
let seed = tmp.path().join("seed");
|
||||
std::fs::create_dir(&seed).unwrap();
|
||||
git(&seed, &["init", "-b", "main"]);
|
||||
std::fs::write(seed.join("seed.txt"), "seed\n").unwrap();
|
||||
git(&seed, &["add", "."]);
|
||||
git(&seed, &["commit", "-m", "one"]);
|
||||
git(&seed, &["remote", "add", "origin", remote.to_str().unwrap()]);
|
||||
git(&seed, &["push", "-u", "origin", "main"]);
|
||||
let member_a = tmp.path().join("member-a");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_a.to_str().unwrap()]);
|
||||
let member_a = dunce::canonicalize(member_a).unwrap();
|
||||
let member_b = tmp.path().join("member-b");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_b.to_str().unwrap()]);
|
||||
let member_b = dunce::canonicalize(member_b).unwrap();
|
||||
git(&member_b, &["checkout", "-b", "feature"]);
|
||||
git(&member_b, &["push", "-u", "origin", "feature"]);
|
||||
// The window's project is the container; the members are attached to it.
|
||||
f.workspace.other_roots.lock().extend([member_a.clone(), member_b.clone()]);
|
||||
|
||||
f.install(&official("git-toolbox"));
|
||||
let item = |host: &PluginHost| {
|
||||
host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch")
|
||||
};
|
||||
let now = Instant::now();
|
||||
let focus = |path: &Path| {
|
||||
f.host
|
||||
.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(path.to_string_lossy().into_owned()))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
f.host.wait_events(Duration::from_secs(10));
|
||||
};
|
||||
// Focusing a file of member `a` shows that member's branch: the window's
|
||||
// project is the container, which is no repository.
|
||||
focus(&member_a.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into()));
|
||||
// Fetch Now fetches the shown member; the container is not a repository.
|
||||
f.run("openidea.git-toolbox", "fetch", None).unwrap();
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(&member_a))),
|
||||
"{member_a:?}: {fetched:?}"
|
||||
);
|
||||
assert_eq!(fetched.iter().filter(|(key, _)| key.starts_with("last-fetch:")).count(), 1, "{fetched:?}");
|
||||
// Switching to the other member follows it.
|
||||
focus(&member_b.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
|
||||
// Idle: reclaimed; the schedule then wakes it to fetch every member (the
|
||||
// container is skipped: it has no repository).
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
f.host.tick();
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()), "the item stays while it sleeps");
|
||||
f.take_events();
|
||||
f.host.tick_at(Instant::now() + Duration::from_secs(301));
|
||||
assert!(f.host.wait_events(Duration::from_secs(30)));
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
for member in [&member_a, &member_b] {
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(member))),
|
||||
"{member:?}: {fetched:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
}
|
||||
|
||||
/// A tiny HTTP server: each request is answered after `delay(n)` (n counts
|
||||
/// requests) with `body`. Returns its port and the request bodies it got.
|
||||
fn http_server(
|
||||
|
||||
@@ -252,6 +252,77 @@ async fn maven_sync() {
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
/// The launch classpath must come from the runner JDK, not the JDK that happened
|
||||
/// to load the language-server model: JDK-activated profiles can select a
|
||||
/// different dependency classifier. Use real Maven so this covers the scenario
|
||||
/// rather than only the probe arguments.
|
||||
#[test]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
|
||||
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
|
||||
let version = release
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
|
||||
.and_then(|line| line.strip_suffix('"'))
|
||||
.expect("JAVA_VERSION in release");
|
||||
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
|
||||
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
|
||||
let jars = root.join("libs");
|
||||
let staging = root.join("empty");
|
||||
std::fs::create_dir_all(&jars).unwrap();
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
|
||||
let status = std::process::Command::new(&jar)
|
||||
.args(["cf"])
|
||||
.arg(jars.join(name))
|
||||
.arg(".")
|
||||
.current_dir(&staging)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
|
||||
<profiles>
|
||||
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
|
||||
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
|
||||
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
|
||||
</dependencies></profile>
|
||||
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
|
||||
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
|
||||
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
|
||||
</dependencies></profile>
|
||||
</profiles>
|
||||
</project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut model = ide_build::load(root, &cache, None);
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
|
||||
}
|
||||
let classpath = ide_build::compile::module_classpath(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
false,
|
||||
None,
|
||||
&root.join("classpath-cache"),
|
||||
&ide_core::CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
|
||||
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
|
||||
assert!(classpath.contains(expected), "{classpath}");
|
||||
assert!(!classpath.contains(other), "{classpath}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
|
||||
host.remote.test_connection(&connection, "/projects").await.unwrap();
|
||||
let local = tmp.path().join("local-deployment");
|
||||
std::fs::create_dir(&local).unwrap();
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
|
||||
|
||||
@@ -223,6 +223,8 @@ impl ClientHandler {
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let mut params = params;
|
||||
crate::virtual_doc::normalize_uris(&mut params);
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -322,11 +324,13 @@ impl ClientHandler {
|
||||
}
|
||||
|
||||
fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
|
||||
let edit: lsp::WorkspaceEdit = params
|
||||
.get("edit")
|
||||
.cloned()
|
||||
.ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned()))
|
||||
.and_then(|e| serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())))?;
|
||||
let edit: lsp::WorkspaceEdit =
|
||||
params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then(
|
||||
|mut e| {
|
||||
crate::virtual_doc::normalize_uris(&mut e);
|
||||
serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))
|
||||
},
|
||||
)?;
|
||||
// Convert before calling the hook: it re-enters document sync.
|
||||
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
|
||||
let applied = match mgr.apply_edit_hook() {
|
||||
|
||||
@@ -809,6 +809,21 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
|
||||
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
|
||||
/// from the old archive. Best-effort: skipped while jdtls is not already running.
|
||||
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
|
||||
let java = LanguageId::new("java");
|
||||
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
|
||||
return Ok(());
|
||||
};
|
||||
if server.running().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
|
||||
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
/// Modification time of the archive behind a cached library class, so a jar
|
||||
/// replaced in place (a SNAPSHOT installed again) is read again.
|
||||
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
@@ -508,6 +511,7 @@ impl LspManager {
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
virtual_stamps: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
@@ -1440,13 +1444,9 @@ impl LspManager {
|
||||
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let stamp = self.injected_request_stamp(¶ms);
|
||||
let fut = async {
|
||||
if t.server.name == "csharp-ls" {
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_csharp_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
} else {
|
||||
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
|
||||
}
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
};
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
|
||||
@@ -3206,14 +3206,44 @@ impl LspManager {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether the archive behind a cached library class changed since it was read.
|
||||
/// Stats the archive: call from a blocking worker.
|
||||
pub fn library_class_changed(&self, uri: &str) -> bool {
|
||||
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
|
||||
virtual_doc::archive_modified(uri) != Some(stamp)
|
||||
}
|
||||
|
||||
/// Archives behind cached library classes that changed since they were read.
|
||||
/// Stats each archive: call from a blocking worker.
|
||||
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
|
||||
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
|
||||
let mut archives: Vec<PathBuf> = uris
|
||||
.iter()
|
||||
.filter(|uri| self.library_class_changed(uri))
|
||||
.filter_map(|uri| virtual_doc::archive(uri))
|
||||
.collect();
|
||||
archives.sort();
|
||||
archives.dedup();
|
||||
archives
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
let cached = self.virtual_docs.lock().get(uri).cloned();
|
||||
if let Some(d) = cached {
|
||||
let stamp = self.virtual_stamps.lock().get(uri).copied();
|
||||
let current = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
|
||||
};
|
||||
if stamp.is_none() || stamp == current {
|
||||
return Ok(d);
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
@@ -3357,11 +3387,18 @@ impl LspManager {
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let stamp = virtual_doc::archive_modified(uri);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
let mut stamps = self.virtual_stamps.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
stamps.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
match stamp {
|
||||
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
|
||||
None => stamps.remove(uri),
|
||||
};
|
||||
doc
|
||||
}
|
||||
|
||||
|
||||
@@ -18,23 +18,36 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
|
||||
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
|
||||
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
|
||||
/// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET
|
||||
/// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps
|
||||
/// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path.
|
||||
/// Escape them in document URI fields (and `WorkspaceEdit.changes` keys) before lsp-types' strict
|
||||
/// URI deserialization, which otherwise rejects the whole response.
|
||||
pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
|
||||
serde_json::Value::Object(fields) => {
|
||||
for (key, value) in fields {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
// Opaque payloads must survive resolve/execute round trips unchanged.
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri")
|
||||
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
|
||||
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
|
||||
&& let Some(uri) = value.as_str().and_then(escaped_uri)
|
||||
{
|
||||
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
|
||||
*value = uri.into();
|
||||
} else if key == "changes"
|
||||
&& let serde_json::Value::Object(changes) = value
|
||||
{
|
||||
*changes = std::mem::take(changes)
|
||||
.into_iter()
|
||||
.map(|(uri, mut edits)| {
|
||||
normalize_uris(&mut edits);
|
||||
(escaped_uri(&uri).unwrap_or(uri), edits)
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
normalize_csharp_uris(value);
|
||||
normalize_uris(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +55,16 @@ pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `uri` with the characters a URI can't hold escaped; `None` when nothing changes or it is
|
||||
/// not a document URI this applies to (other schemes are passed back to servers as given).
|
||||
fn escaped_uri(uri: &str) -> Option<String> {
|
||||
if !(uri.starts_with("file:") || uri.starts_with("csharp:")) {
|
||||
return None;
|
||||
}
|
||||
let escaped = utf8_percent_encode(uri, URI_ESCAPES).to_string();
|
||||
(escaped != uri).then_some(escaped)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CSharpMetadata {
|
||||
@@ -149,6 +172,20 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive(uri: &str) -> Option<std::path::PathBuf> {
|
||||
match scheme(uri) {
|
||||
"jar" => Some(crate::library_source::parse(uri)?.jar),
|
||||
"jdt" => description(uri).filter(|p| p.ends_with(".jar") || p.ends_with(".jmod")).map(Into::into),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modification time of the archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive_modified(uri: &str) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(archive(uri)?).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
@@ -202,7 +239,7 @@ mod tests {
|
||||
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
|
||||
]);
|
||||
let original = response.clone();
|
||||
normalize_csharp_uris(&mut response);
|
||||
normalize_uris(&mut response);
|
||||
assert_eq!(response[0]["uri"], encoded);
|
||||
assert_eq!(response[0]["source"], uri);
|
||||
assert_eq!(response[1]["location"]["targetUri"], encoded);
|
||||
@@ -210,6 +247,31 @@ mod tests {
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
|
||||
/// with the characters unescaped, and the resolved code action failed to deserialize.
|
||||
#[test]
|
||||
fn file_uris_with_unicode_and_spaces_deserialize_in_workspace_edits() {
|
||||
let uri = "file:///C:/项目/my app/src/A.java";
|
||||
let encoded = "file:///C:/%E9%A1%B9%E7%9B%AE/my%20app/src/A.java";
|
||||
let edit = serde_json::json!([{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "newText": "x" }]);
|
||||
let mut action = serde_json::json!({
|
||||
"title": "Extract to local variable",
|
||||
"edit": {
|
||||
"documentChanges": [{ "textDocument": { "uri": uri, "version": 3 }, "edits": edit }],
|
||||
"changes": { (uri): edit },
|
||||
},
|
||||
"data": { "uri": uri },
|
||||
});
|
||||
assert!(serde_json::from_value::<lsp_types::CodeAction>(action.clone()).is_err());
|
||||
normalize_uris(&mut action);
|
||||
let action: lsp_types::CodeAction = serde_json::from_value(action.clone()).unwrap();
|
||||
let edit = action.edit.unwrap();
|
||||
let Some(lsp_types::DocumentChanges::Edits(edits)) = edit.document_changes else { panic!() };
|
||||
assert_eq!(edits[0].text_document.uri.as_str(), encoded);
|
||||
assert_eq!(edit.changes.unwrap().keys().next().unwrap().as_str(), encoded);
|
||||
assert_eq!(action.data.unwrap()["uri"], uri, "opaque data is passed back unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdt_titles() {
|
||||
assert_eq!(title(JDT, true), "SpringApplication.class");
|
||||
@@ -256,6 +318,29 @@ mod tests {
|
||||
assert_eq!(description("foo://x/y"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_archive_behind_a_library_class() {
|
||||
assert_eq!(archive(JDT), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar".into()));
|
||||
assert_eq!(
|
||||
archive("jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(archive("jdt://contents/a.jar/p/C.class"), None);
|
||||
assert_eq!(archive("file:///a/C.java"), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jar = dir.path().join("lib-1.0-SNAPSHOT.jar");
|
||||
std::fs::write(&jar, b"PK").unwrap();
|
||||
let uri = format!("jar:file://{}!/p/C.class", jar.display());
|
||||
assert_eq!(archive(&uri), Some(jar.clone()));
|
||||
let before = archive_modified(&uri).unwrap();
|
||||
let file = std::fs::File::options().write(true).open(&jar).unwrap();
|
||||
file.set_modified(before + std::time::Duration::from_secs(5)).unwrap();
|
||||
assert_ne!(archive_modified(&uri), Some(before));
|
||||
std::fs::remove_file(&jar).unwrap();
|
||||
assert_eq!(archive_modified(&uri), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and
|
||||
//! its token, for the stdio bridge. Readable only by the user; removed when
|
||||
//! the server stops.
|
||||
//! the server stops. `<app-data>/mcp/token` keeps the token across runs when
|
||||
//! the user chose a fixed token.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
@@ -14,6 +15,28 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
|
||||
app_data.join(DISCOVERY_FILE)
|
||||
}
|
||||
|
||||
/// The kept token, relative to the app data directory.
|
||||
pub const TOKEN_FILE: &str = "mcp/token";
|
||||
|
||||
/// The kept token of the IDE whose app data directory is `app_data`.
|
||||
pub fn token_path(app_data: &Path) -> PathBuf {
|
||||
app_data.join(TOKEN_FILE)
|
||||
}
|
||||
|
||||
/// The token kept at `path`; a new one is made and kept there when there is none (or the
|
||||
/// file doesn't hold one). Readable only by the user.
|
||||
pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
let token = text.trim();
|
||||
if token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Ok(token.to_owned());
|
||||
}
|
||||
}
|
||||
let token = crate::server::new_token()?;
|
||||
write_atomic(path, token.as_bytes())?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
pub port: u16,
|
||||
@@ -32,15 +55,8 @@ impl Discovery {
|
||||
|
||||
/// Writes the file atomically, readable only by the user.
|
||||
pub fn write(&self, path: &Path) -> io::Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
|
||||
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
write_atomic(path, &bytes)
|
||||
}
|
||||
|
||||
/// Removes the file at `path` if it still describes this process's server.
|
||||
@@ -51,6 +67,18 @@ impl Discovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` atomically, readable only by the user.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
|
||||
let written = write_private(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
@@ -91,4 +119,23 @@ mod tests {
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_one_token_until_its_file_is_removed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = token_path(tmp.path());
|
||||
let first = kept_token(&path).unwrap();
|
||||
assert_eq!(first.len(), 64);
|
||||
assert_eq!(kept_token(&path).unwrap(), first, "the same token on the next run");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
std::fs::write(&path, "not a token").unwrap();
|
||||
let replaced = kept_token(&path).unwrap();
|
||||
assert_ne!(replaced, first);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_ne!(kept_token(&path).unwrap(), replaced, "a removed token is made anew");
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ mod protocol;
|
||||
mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
|
||||
pub use server::McpServer;
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -49,12 +49,25 @@ struct Shared {
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
token: String,
|
||||
discovery: Option<PathBuf>,
|
||||
shutdown: watch::Sender<bool>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
/// The bearer token; `None`: a new one.
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
/// Starts listening on `127.0.0.1:<random port>` with a new token, on
|
||||
/// `runtime`, and writes the discovery file at `discovery` if given.
|
||||
@@ -63,10 +76,32 @@ impl McpServer {
|
||||
discovery: Option<PathBuf>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
) -> std::io::Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Self::start_with(registry, discovery, runtime, ServerOptions::default())
|
||||
}
|
||||
|
||||
/// [`Self::start`] on the port and with the token of `options`.
|
||||
pub fn start_with(
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
discovery: Option<PathBuf>,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let token = new_token()?;
|
||||
let token = match options.token {
|
||||
Some(token) => token,
|
||||
None => new_token()?,
|
||||
};
|
||||
let listener = {
|
||||
let _entered = runtime.enter();
|
||||
tokio::net::TcpListener::from_std(listener)?
|
||||
@@ -91,13 +126,18 @@ impl McpServer {
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, token, discovery, shutdown, cancel })
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Why the port of [`ServerOptions::port`] is not the one listened on.
|
||||
pub fn port_unavailable(&self) -> Option<&str> {
|
||||
self.port_unavailable.as_deref()
|
||||
}
|
||||
|
||||
/// The bearer token of this run.
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The server over real HTTP, and the stdio bridge in front of it, with a
|
||||
//! registry standing in for the IDE host.
|
||||
|
||||
use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
|
||||
use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -366,3 +366,30 @@ async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
|
||||
let (_, body) = post(&s, "/mcp", &headers, &call).await;
|
||||
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_one() {
|
||||
let mut s = setup();
|
||||
s.server.take().unwrap().stop();
|
||||
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let token = "ab".repeat(32);
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, options.clone()).unwrap());
|
||||
assert_eq!((s.server().port(), s.server().token()), (free, token.as_str()));
|
||||
assert!(s.server().port_unavailable().is_none());
|
||||
assert_eq!(Discovery::read(&s.discovery).unwrap().port, free);
|
||||
let list = rpc(&s, "tools/list", json!({})).await;
|
||||
assert_eq!(list["result"]["tools"][0]["name"], "echo");
|
||||
|
||||
// The port is taken (by the first server): a second one listens elsewhere and says why.
|
||||
let second = McpServer::start_with(registry, None, &runtime, options).unwrap();
|
||||
assert_ne!(second.port(), free);
|
||||
assert!(
|
||||
second.port_unavailable().is_some_and(|why| why.contains(&free.to_string())),
|
||||
"{:?}",
|
||||
second.port_unavailable()
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,11 @@ impl RemoteService {
|
||||
}
|
||||
|
||||
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
|
||||
self.fs_with(connection, None).await
|
||||
}
|
||||
|
||||
/// Like [`Self::fs`], with an FTP password typed but not saved yet (`None`: the saved one).
|
||||
async fn fs_with(&self, connection: &FsConnection, password: Option<String>) -> IdeResult<RemoteFs> {
|
||||
match connection {
|
||||
FsConnection::Local => Ok(RemoteFs::Local),
|
||||
FsConnection::Sftp { ssh } => {
|
||||
@@ -90,14 +95,24 @@ impl RemoteService {
|
||||
Ok(RemoteFs::new(session.sftp().await?).with_shell(session))
|
||||
}
|
||||
FsConnection::Ftp { server, secure, ftp } => {
|
||||
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
|
||||
let password = password.or_else(|| self.ssh.password(&ide_ssh::ftp::secret_key(server)));
|
||||
Ok(RemoteFs::Ftp(ide_ssh::ftp::FtpFs::connect(ftp, *secure, password).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
self.test_connection_with(connection, path, None).await
|
||||
}
|
||||
|
||||
/// Tests `connection` with an FTP password the settings have not saved yet.
|
||||
pub async fn test_connection_with(
|
||||
&self,
|
||||
connection: &FsConnection,
|
||||
path: &str,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let fs = self.fs_with(connection, password).await?;
|
||||
let result = fs.test(path).await;
|
||||
fs.close().await;
|
||||
result
|
||||
|
||||
@@ -323,6 +323,18 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
|
||||
}
|
||||
let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
if use_wrapper && wrapper.is_file() {
|
||||
// `gradlew` can't start without its jar, which `.gitignore`s with `*.jar` often leave
|
||||
// out: run the distribution the wrapper already unpacked instead.
|
||||
if build_dir.join("gradle/wrapper/gradle-wrapper.jar").is_file() {
|
||||
return wrapper;
|
||||
}
|
||||
let home = setting_path(root, &g.user_home)
|
||||
.or_else(|| std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from))
|
||||
.or_else(|| Some(crate::home_dir()?.join(".gradle")));
|
||||
if let Some(gradle) = home.and_then(|home| unpacked_wrapper_distribution(build_dir, &home)) {
|
||||
return gradle;
|
||||
}
|
||||
// Nothing to run instead: the wrapper's failure explains what is missing.
|
||||
return wrapper;
|
||||
}
|
||||
pinned::pinned_install(build_dir, Tool::Gradle)
|
||||
@@ -330,6 +342,21 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
|
||||
.unwrap_or_else(|| name.into())
|
||||
}
|
||||
|
||||
/// `bin/gradle` of the distribution `gradle/wrapper/gradle-wrapper.properties` names, once the
|
||||
/// wrapper has unpacked it in `<gradle_user_home>/wrapper/dists` (and marked it complete).
|
||||
fn unpacked_wrapper_distribution(build_dir: &Path, gradle_user_home: &Path) -> Option<PathBuf> {
|
||||
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
|
||||
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
|
||||
let zip = url.trim().replace("\\:", ":").rsplit('/').next()?.to_owned();
|
||||
let name = zip.strip_suffix(".zip")?;
|
||||
let gradle = if cfg!(windows) { "gradle.bat" } else { "gradle" };
|
||||
// One directory per distribution URL hash, each holding the unpacked `gradle-<version>`.
|
||||
let hashes = std::fs::read_dir(gradle_user_home.join("wrapper/dists").join(name)).ok()?;
|
||||
hashes.flatten().map(|e| e.path()).filter(|dir| dir.join(format!("{zip}.ok")).is_file()).find_map(|dir| {
|
||||
std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path().join("bin").join(gradle)).find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
fn gradle_run_jdk<'a>(
|
||||
release: Option<u32>,
|
||||
configured: Option<&'a crate::java::Jdk>,
|
||||
@@ -350,6 +377,11 @@ fn gradle_run_jdk<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest installed JDK that Gradle `gradle` runs on.
|
||||
fn newest_gradle_jdk(gradle: (u32, u32), installed: &[crate::java::Jdk]) -> Option<&crate::java::Jdk> {
|
||||
installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).max_by_key(|jdk| jdk.major)
|
||||
}
|
||||
|
||||
/// Default environment shared by Gradle model builds and Run configurations.
|
||||
pub fn gradle_build_env(
|
||||
root: &Path,
|
||||
@@ -466,7 +498,17 @@ pub fn configure_gradle_runtime(cfg: &mut LanguageServerConfig, root: &Path, bui
|
||||
.any(|f| build_dir.join(f).is_file());
|
||||
let env = if gradle_build {
|
||||
let command = gradle_command(root, build_dir, s);
|
||||
gradle_build_env(root, build_dir, &command.to_string_lossy(), s, None)
|
||||
let command = command.to_string_lossy();
|
||||
let mut env = gradle_build_env(root, build_dir, &command, s, None);
|
||||
// No Project JDK and no Java version the build declares: jdtls would import on its own
|
||||
// runtime, which an older Gradle cannot run on. Use an installed JDK this Gradle supports.
|
||||
if !env.contains_key("JAVA_HOME")
|
||||
&& let Some(gradle) = gradle_distribution_version(build_dir, &command)
|
||||
&& let Some(jdk) = newest_gradle_jdk(gradle, &crate::java::installed_jdks())
|
||||
{
|
||||
env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
env
|
||||
} else {
|
||||
gradle_env(root, s, Default::default())
|
||||
};
|
||||
@@ -2001,6 +2043,11 @@ mod gradle_runtime_tests {
|
||||
assert_eq!(gradle_run_jdk(Some(21), None, &installed, None).unwrap().major, 21);
|
||||
assert_eq!(gradle_run_jdk(Some(19), None, &installed, None).unwrap().major, 21);
|
||||
assert!(gradle_run_jdk(None, None, &installed, None).is_none());
|
||||
// The Java import of a build that declares no Java version still needs a JVM its Gradle runs on.
|
||||
assert_eq!(newest_gradle_jdk((8, 4), &installed).unwrap().major, 17);
|
||||
assert_eq!(newest_gradle_jdk((7, 2), &installed).unwrap().major, 11);
|
||||
assert_eq!(newest_gradle_jdk((9, 1), &installed).unwrap().major, 25);
|
||||
assert!(newest_gradle_jdk((9, 0), &installed[4..]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.13.0",
|
||||
"1.14.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1866,6 +1866,37 @@ fn max_java_picks_an_older_jdk() {
|
||||
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
|
||||
}
|
||||
|
||||
/// #3086: `gradle-wrapper.jar` excluded by a `*.jar` rule in `.gitignore`: `gradlew` can't
|
||||
/// start, so the distribution the wrapper already unpacked runs instead.
|
||||
#[test]
|
||||
fn gradle_wrapper_without_its_jar_runs_the_unpacked_distribution() {
|
||||
use crate::apply::gradle_command;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let plain = if cfg!(windows) { "gradle.bat" } else { "gradle" };
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/wrapper/gradle-wrapper.properties"),
|
||||
"distributionUrl=https\\://services.gradle.org/distributions/gradle-4.10-bin.zip\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
let hash = root.join("user-home/wrapper/dists/gradle-4.10-bin/7ddd1b3f");
|
||||
let gradle = hash.join("gradle-4.10/bin").join(plain);
|
||||
std::fs::create_dir_all(gradle.parent().unwrap()).unwrap();
|
||||
std::fs::write(&gradle, "").unwrap();
|
||||
// Not marked complete: possibly half unpacked.
|
||||
assert_eq!(gradle_command(root, root, &settings), wrapper);
|
||||
std::fs::write(hash.join("gradle-4.10-bin.zip.ok"), "").unwrap();
|
||||
assert_eq!(gradle_command(root, root, &settings), gradle);
|
||||
// With its jar the wrapper runs as usual.
|
||||
std::fs::write(root.join("gradle/wrapper/gradle-wrapper.jar"), "").unwrap();
|
||||
assert_eq!(gradle_command(root, root, &settings), wrapper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_settings_precedence() {
|
||||
use crate::apply::{gradle_command, gradle_env};
|
||||
|
||||
@@ -110,6 +110,11 @@ impl SshManager {
|
||||
self.entered.lock().get(key).cloned().or_else(|| self.saved.get(key))
|
||||
}
|
||||
|
||||
/// Whether a password is saved under `key` (not one only entered this session).
|
||||
pub fn password_saved(&self, key: &str) -> bool {
|
||||
self.saved.get(key).is_some()
|
||||
}
|
||||
|
||||
/// The known password / passphrase of configuration `id`.
|
||||
pub fn secret(&self, config: &SshConfiguration) -> Option<String> {
|
||||
if let Some(s) = self.entered.lock().get(&config.id) {
|
||||
|
||||
@@ -281,6 +281,9 @@ impl Repo {
|
||||
let commitish = arg(commitish, "revision")?;
|
||||
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
|
||||
if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) {
|
||||
if crate::write::renamed_remote_start(self, b, &commitish) {
|
||||
args.push("--no-track".into());
|
||||
}
|
||||
args.extend(["-b".into(), arg(b, "branch name")?]);
|
||||
}
|
||||
args.extend(["--".into(), path, commitish]);
|
||||
|
||||
@@ -800,16 +800,41 @@ pub(crate) fn create_branch(
|
||||
let repo = r.gix()?;
|
||||
resolve(&repo, s)?;
|
||||
}
|
||||
let no_track = start.is_some_and(|s| renamed_remote_start(r, name, s));
|
||||
let mut args = match (checkout, overwrite) {
|
||||
(true, false) => vec!["switch", "-c", name],
|
||||
(true, true) => vec!["switch", "-C", name],
|
||||
(false, false) => vec!["branch", "--", name],
|
||||
(false, true) => vec!["branch", "--force", "--", name],
|
||||
(false, false) => vec!["branch"],
|
||||
(false, true) => vec!["branch", "--force"],
|
||||
};
|
||||
if no_track {
|
||||
args.push("--no-track");
|
||||
}
|
||||
if !checkout {
|
||||
args.extend(["--", name]);
|
||||
}
|
||||
args.extend(start);
|
||||
run(r.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Whether `start` is a remote-tracking branch with another name than the new branch
|
||||
/// `name`. Git would make it the upstream, so pushing the new branch would go to the
|
||||
/// start branch (`feature` from `origin/main` pushes to `main`); such a branch is left
|
||||
/// without upstream and gets its own one on the first push. A remote branch under the
|
||||
/// same name stays tracked.
|
||||
pub(crate) fn renamed_remote_start(r: &Repo, name: &str, start: &str) -> bool {
|
||||
let Ok(out) = cli::run_raw(r.root(), &["rev-parse", "--verify", "--quiet", "--symbolic-full-name", start], None)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let full = out.stdout_str().trim().to_string();
|
||||
let Some(rest) = full.strip_prefix("refs/remotes/").filter(|_| out.code == Some(0)) else { return false };
|
||||
let Ok(repo) = r.gix() else { return false };
|
||||
let names = repo.remote_names();
|
||||
let branch = names.iter().find_map(|remote| rest.strip_prefix(&format!("{remote}/")));
|
||||
branch.is_some_and(|b| b != name)
|
||||
}
|
||||
|
||||
/// Undo Commit: moves the current branch back to the parent of `commit` (which must be
|
||||
/// HEAD), keeping its changes staged. Returns the undone commit's message.
|
||||
/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top
|
||||
|
||||
@@ -984,15 +984,41 @@ fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_branch_from_remote_branch_tracks_it() {
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
fn new_branch_from_remote_branch_tracks_it_only_under_the_same_name() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
fx.git(&["push", "-q", "origin", "main:release"]);
|
||||
fx.git(&["fetch", "-q"]);
|
||||
let repo = fx.repo();
|
||||
let upstream = |name: &str| {
|
||||
repo.branches().unwrap().into_iter().find(|b| b.name == name && !b.remote).and_then(|b| b.upstream)
|
||||
};
|
||||
|
||||
repo.create_branch("release", Some("origin/release"), false).unwrap();
|
||||
assert_eq!(upstream("release").as_deref(), Some("origin/release"));
|
||||
|
||||
// Another name: no upstream, whether switched to or not, created plainly or overwritten.
|
||||
repo.create_branch("other", Some("origin/release"), false).unwrap();
|
||||
assert_eq!(upstream("other"), None);
|
||||
repo.create_branch_with_overwrite("other", Some("origin/main"), false, true).unwrap();
|
||||
assert_eq!(upstream("other"), None);
|
||||
repo.create_branch("my-release", Some("origin/release"), true).unwrap();
|
||||
let info = repo.branch_info().unwrap();
|
||||
assert_eq!(info.head.as_deref(), Some("my-release"));
|
||||
assert_eq!(info.upstream.as_deref(), Some("origin/release"));
|
||||
assert_eq!(info.upstream, None);
|
||||
|
||||
// The first push then goes to a branch of its own name and tracks it.
|
||||
fx.write("a.txt", "mine\n");
|
||||
let tip = fx.commit_all("mine");
|
||||
let (cancel, out) = quiet();
|
||||
repo.push(false, true, &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare, &["rev-parse", "my-release"]), tip);
|
||||
assert_ne!(git_in(&bare, &["rev-parse", "release"]), tip);
|
||||
assert_eq!(fx.git(&["config", "branch.my-release.merge"]), "refs/heads/my-release");
|
||||
|
||||
// New worktree on a new branch from a remote branch: the same rule.
|
||||
let wt = fx.tmp.path().join("wt");
|
||||
repo.add_worktree(&wt, "origin/release", Some("wt-branch")).unwrap();
|
||||
assert_eq!(upstream("wt-branch"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -389,6 +389,12 @@ impl Documents {
|
||||
self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(()))
|
||||
}
|
||||
|
||||
/// [`Self::apply_workspace_edit`] for an agent's edit (the MCP server's tools): undoable the
|
||||
/// same way, announced as an agent's (`by_agent`) rather than as a refactoring.
|
||||
pub fn apply_agent_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
self.apply_workspace_edit_as(edit, true, true, &mut |_| Ok(()))
|
||||
}
|
||||
|
||||
/// File moves already own a combined move/reference undo record.
|
||||
pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(()))
|
||||
@@ -399,6 +405,16 @@ impl Documents {
|
||||
edit: &WorkspaceEdit,
|
||||
remember: bool,
|
||||
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
self.apply_workspace_edit_as(edit, remember, false, before_operation)
|
||||
}
|
||||
|
||||
fn apply_workspace_edit_as(
|
||||
&self,
|
||||
edit: &WorkspaceEdit,
|
||||
remember: bool,
|
||||
by_agent: bool,
|
||||
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
let _operation = self.operations.lock();
|
||||
if edit.files.is_empty() {
|
||||
@@ -485,7 +501,7 @@ impl Documents {
|
||||
});
|
||||
}
|
||||
history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE);
|
||||
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected });
|
||||
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected, by_agent });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -118,9 +118,20 @@ SQL completion knows the connected schema: tables after `FROM`/`JOIN` (with join
|
||||
|
||||
## Results and data editing
|
||||
|
||||
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12).
|
||||
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12, pinned tabs excepted).
|
||||
|
||||
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last while the result is open.
|
||||
Running a console again replaces its previous results: its unpinned result tabs make way for the new run, which gets one tab per result set (**Result N**, or **Result N**, **Result N-2**, … when several selected statements return rows). A run with no result sets, such as an `UPDATE`, keeps the previous results. Results from other consoles stay.
|
||||
|
||||
To keep a result, pin it with **Pin Tab** in the result tab's context menu or the pin button on the result toolbar; the tab's close button becomes a pin button that unpins it, so a stray click or middle-click can't close it, and later runs open new tabs instead. Rerunning or paging a pinned result updates it in place.
|
||||
|
||||
<Warning>
|
||||
Replacing a result discards changes you have not submitted in it. **Submit** or **Revert** them, or pin the tab,
|
||||
before running the console again.
|
||||
</Warning>
|
||||
|
||||
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Close All but Pinned**, **Pin Tab** / **Unpin Tab**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last until the result is closed or replaced.
|
||||
|
||||
With focus on a result tab or inside a result, use `⇧⌘]` / `⇧⌘[` in the IntelliJ macOS keymap (`⌃→` / `⌃←` also work), or `Alt+→` / `Alt+←` in IntelliJ Windows/Linux. VS Code and Eclipse presets use their respective editor-tab navigation defaults. Switching focuses the destination result tab. Result navigation stays scoped to Results and is blocked while a menu or dialog is open. Customize database result shortcuts independently of editor shortcuts in **Settings › Keymap**.
|
||||
|
||||
Closing a selected result activates its left neighbor, or its right neighbor if there is no result on the left. When no results remain, **Output** is selected. Closing an inactive result keeps the current selection.
|
||||
|
||||
|
||||
@@ -18,14 +18,17 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The server listens on `127.0.0.1` only, on a random port, and every request must carry a token that changes each time OpenIDEA starts. Requests from another origin or host are refused.
|
||||
The server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin or host are refused.
|
||||
|
||||
- **Port.** By default it listens on the same port, 26371, in every run (**Use a fixed port** and **Port**), so HTTP clients and port forwards (an SSH tunnel to another machine, say) keep working after a restart. When the port is in use or reserved, that run takes a free port and **Status** says why. Turn **Use a fixed port** off for a free port in every run.
|
||||
- **Token.** By default the token changes each time OpenIDEA starts. Turn on **Keep the token** to keep one across runs, stored in OpenIDEA's application data for your account only; **Regenerate Token** replaces it, and clients configured with the old one stop working.
|
||||
|
||||
## Connect a client
|
||||
|
||||
The settings page has two buttons under **Client configuration**. Both copy an `mcpServers` entry in the JSON format most clients accept.
|
||||
|
||||
- **Copy Stdio Config** (recommended): the client starts OpenIDEA's executable with `--mcp-stdio`, which relays to the running IDE. It holds no token and keeps working after OpenIDEA restarts.
|
||||
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It stops working when OpenIDEA restarts, because the port and token change.
|
||||
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
|
||||
|
||||
The stdio configuration looks like this (the path is the one on your machine):
|
||||
|
||||
|
||||
@@ -204,6 +204,8 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -268,6 +270,7 @@ by hand:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -118,9 +118,19 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
|
||||
|
||||
## 结果与数据编辑
|
||||
|
||||
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个)。
|
||||
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个,已固定的标签页除外)。
|
||||
|
||||
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭前一直有效。
|
||||
再次运行控制台会替换它之前的结果:它未固定的结果标签页会让位给新的运行,新运行每个结果集一个标签页(**Result N**;选中的多条语句都返回行时为 **Result N**、**Result N-2**……)。没有结果集的运行(如 `UPDATE`)会保留之前的结果。其他控制台的结果不受影响。
|
||||
|
||||
要保留某个结果,可在结果标签页的右键菜单中选择 **固定标签页**,或点击结果工具栏上的固定按钮;标签页的关闭按钮会变成固定按钮,点击即可取消固定,误点或中键点击都不会关闭它;之后的运行会改为打开新标签页。重新运行或翻页已固定的结果时,会在原标签页中更新。
|
||||
|
||||
<Warning>
|
||||
替换结果会丢弃其中尚未提交的更改。再次运行控制台前,请先 **提交** 或 **还原** 这些更改,或固定该标签页。
|
||||
</Warning>
|
||||
|
||||
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**关闭除已固定外的所有标签页**、**固定标签页** / **取消固定标签页**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭或被替换前一直有效。
|
||||
|
||||
焦点位于结果标签页或结果内容中时,IntelliJ macOS 快捷键方案使用 `⇧⌘]` / `⇧⌘[`(也支持 `⌃→` / `⌃←`),IntelliJ Windows/Linux 使用 `Alt+→` / `Alt+←`。VS Code 和 Eclipse 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
|
||||
|
||||
关闭当前选中的结果标签页时,优先选中左侧相邻的结果;没有左侧结果时,选中右侧相邻结果。没有剩余结果时,选中 **输出**。关闭未选中的结果不会改变当前选择。
|
||||
|
||||
|
||||
@@ -17,14 +17,17 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
服务器只监听 `127.0.0.1` 上的一个随机端口,每个请求都必须带上令牌,令牌在每次启动 OpenIDEA 时都会变化。来自其他 Origin 或 Host 的请求会被拒绝。
|
||||
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
|
||||
|
||||
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
|
||||
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
|
||||
|
||||
## 连接客户端
|
||||
|
||||
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `mcpServers` JSON 配置。
|
||||
|
||||
- **复制 Stdio 配置**(推荐):客户端以 `--mcp-stdio` 参数启动 OpenIDEA 的可执行文件,由它转发给正在运行的 IDE。配置里没有令牌,OpenIDEA 重启后依然可用。
|
||||
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。由于端口和令牌会变,OpenIDEA 重启后这份配置就失效了。
|
||||
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
|
||||
|
||||
stdio 配置大致如下(路径以你机器上的为准):
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -239,6 +241,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
+43
-14
@@ -71,7 +71,14 @@ async function openReply(page: Page, text: string, imageData?: string, theme?: "
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{
|
||||
type: "user",
|
||||
text: "Show Markdown",
|
||||
attachments: [],
|
||||
sentMs: 0,
|
||||
doneMs: 1,
|
||||
steered: false,
|
||||
},
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
@@ -111,7 +118,11 @@ test("scrolls a wide table inside the reply", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const sizes = await reply.evaluate((el) => {
|
||||
const table = el.querySelector("table")!;
|
||||
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
|
||||
return {
|
||||
reply: el.clientWidth,
|
||||
table: table.clientWidth,
|
||||
content: table.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
|
||||
expect(sizes.content).toBeGreaterThan(sizes.table);
|
||||
@@ -148,7 +159,10 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
let fetched = 0;
|
||||
await page.route("https://images.example.com/code.gif", (route) => {
|
||||
fetched++;
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
});
|
||||
const reply = await openReply(
|
||||
page,
|
||||
@@ -158,7 +172,9 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
const width = (name: string) =>
|
||||
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
|
||||
await expect.poll(() => width("Local QR")).toBe(1);
|
||||
const load = reply.getByRole("button", { name: "Load image from images.example.com" });
|
||||
const load = reply.getByRole("button", {
|
||||
name: "Load image from images.example.com",
|
||||
});
|
||||
await expect(load).toBeVisible();
|
||||
expect(fetched).toBe(0);
|
||||
await load.click();
|
||||
@@ -203,12 +219,10 @@ test("keeps latest reply actions visible and quotes into the draft without shift
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
|
||||
const quoted = REPLY.trimEnd().split("\n");
|
||||
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
|
||||
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
|
||||
});
|
||||
|
||||
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
|
||||
@@ -231,7 +245,14 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
const user = (text: string) => ({
|
||||
type: "user",
|
||||
text,
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 5001,
|
||||
steered: false,
|
||||
});
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
@@ -326,7 +347,9 @@ test("keeps hover actions reachable when the pointer leaves the message body", a
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
@@ -468,7 +491,10 @@ for (const theme of ["dark", "light"] as const) {
|
||||
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
|
||||
const fg = luminance(getComputedStyle(el).fill);
|
||||
const bg = luminance(getComputedStyle(bar).fill);
|
||||
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
|
||||
return {
|
||||
text: el.textContent,
|
||||
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
|
||||
};
|
||||
});
|
||||
});
|
||||
expect(contrasts).toHaveLength(4);
|
||||
@@ -480,7 +506,10 @@ test("draws a diagram without reaching the hosts its configuration names", async
|
||||
const asked: string[] = [];
|
||||
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
|
||||
asked.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
});
|
||||
const at = (name: string) => `https://images.example.com/${name}`;
|
||||
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const expected =
|
||||
'[\n {\n "id": 9223372036854775807,\n "amount": 1.0000000000000000002,\n "note": "9007199254740993",\n "empty": null\n }\n]';
|
||||
|
||||
async function seedResult(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
window.__ipc.handlers.clipboard_write_text = () => null;
|
||||
window.__ipc.handlers.db_export_file = () => null;
|
||||
window.__ipc.handlers["plugin:path|resolve_directory"] = () => "/tmp";
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { useDatabase, RESULTS_VIEW } = await import(storePath);
|
||||
const { showServiceView } = await import(contributionsPath);
|
||||
const text = "SELECT id, amount, note, empty FROM numbers";
|
||||
useDatabase.setState({
|
||||
loaded: true,
|
||||
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
|
||||
consoles: { "exact-console.sql": "exact" },
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Result 1",
|
||||
path: "exact-console.sql",
|
||||
sourceId: "exact",
|
||||
outcome: {
|
||||
statement: { text, from: 0, to: text.length },
|
||||
error: null,
|
||||
result: {
|
||||
statement: text,
|
||||
columns: [
|
||||
{ name: "id", typeName: "BIGINT" },
|
||||
{ name: "amount", typeName: "NUMERIC(38,19)" },
|
||||
{ name: "note", typeName: "TEXT" },
|
||||
{ name: "empty", typeName: "TEXT" },
|
||||
],
|
||||
rows: [["9223372036854775807", "1.0000000000000000002", "9007199254740993", null]],
|
||||
affected: null,
|
||||
hasMore: false,
|
||||
elapsedMs: 1,
|
||||
executionMs: 1,
|
||||
},
|
||||
},
|
||||
page: 0,
|
||||
pageSize: 500,
|
||||
},
|
||||
],
|
||||
activeResult: 1,
|
||||
});
|
||||
showServiceView(RESULTS_VIEW);
|
||||
});
|
||||
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(4);
|
||||
}
|
||||
|
||||
async function openExport(page: Page) {
|
||||
await page.getByRole("button", { name: "Export Data", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Export Data…", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Export Data", exact: true });
|
||||
await dialog.getByRole("button", { name: "Extractor", exact: true }).click();
|
||||
await page.getByRole("option", { name: "JSON", exact: true }).click();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test("Copy as JSON preserves exact numeric values", async ({ workbench: page }) => {
|
||||
await seedResult(page);
|
||||
await page.getByRole("button", { name: "Export Data", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Copy as JSON", exact: true }).click();
|
||||
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
|
||||
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
|
||||
});
|
||||
|
||||
test("Export Data preserves exact preview and clipboard values", async ({ workbench: page }) => {
|
||||
await seedResult(page);
|
||||
const dialog = await openExport(page);
|
||||
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
|
||||
await dialog.getByRole("button", { name: "Copy to Clipboard", exact: true }).click();
|
||||
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
|
||||
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
|
||||
});
|
||||
|
||||
test("Export Data preserves exact preview and exported file values", async ({ workbench: page }) => {
|
||||
await seedResult(page);
|
||||
const dialog = await openExport(page);
|
||||
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
|
||||
await dialog.getByRole("textbox", { name: "Output file", exact: true }).fill("/tmp/exact-numbers.json");
|
||||
await dialog.getByRole("button", { name: "Export to File", exact: true }).click();
|
||||
await expect.poll(() => ipcCalls(page, "db_export_file")).toHaveLength(1);
|
||||
expect((await ipcCalls(page, "db_export_file"))[0]).toEqual({ path: "/tmp/exact-numbers.json", text: expected });
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const integers = ["9007199254740993", "9007199254740992", "-9007199254740992", "-9007199254740993"];
|
||||
const decimals = ["1.0000000000000000002", "1.0000000000000000001", "-0.0000000000000000001", "0"];
|
||||
|
||||
async function seedResult(page: Page, values: string[], type: string) {
|
||||
await page.evaluate(
|
||||
async ({ values, type }) => {
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { useDatabase, RESULTS_VIEW } = await import(storePath);
|
||||
const { showServiceView } = await import(contributionsPath);
|
||||
const text = "SELECT value FROM exact_numbers";
|
||||
useDatabase.setState({
|
||||
loaded: true,
|
||||
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
|
||||
consoles: { "exact-console.sql": "exact" },
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
title: "Result 1",
|
||||
path: "exact-console.sql",
|
||||
sourceId: "exact",
|
||||
outcome: {
|
||||
statement: { text, from: 0, to: text.length },
|
||||
error: null,
|
||||
result: {
|
||||
statement: text,
|
||||
columns: [{ name: "value", typeName: type }],
|
||||
rows: values.map((value) => [value]),
|
||||
affected: null,
|
||||
hasMore: false,
|
||||
elapsedMs: 1,
|
||||
executionMs: 1,
|
||||
},
|
||||
},
|
||||
page: 0,
|
||||
pageSize: 500,
|
||||
},
|
||||
],
|
||||
activeResult: 1,
|
||||
});
|
||||
showServiceView(RESULTS_VIEW);
|
||||
},
|
||||
{ values, type },
|
||||
);
|
||||
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(
|
||||
values.length,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [name, values, type, ascending] of [
|
||||
["BIGINT", integers, "BIGINT", [integers[3]!, integers[2]!, integers[1]!, integers[0]!]],
|
||||
["DECIMAL", decimals, "NUMERIC(38,19)", [decimals[2]!, decimals[3]!, decimals[1]!, decimals[0]!]],
|
||||
] as const) {
|
||||
test(`${name}: result headers sort exact values ascending, descending and back to query order`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await seedResult(page, [...values], type);
|
||||
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
|
||||
const cells = grid.getByRole("gridcell");
|
||||
const header = grid.getByRole("columnheader", { name: /^value\b/ }).getByText("value", { exact: true });
|
||||
await expect(cells).toHaveText([...values]);
|
||||
await header.click();
|
||||
await expect(cells).toHaveText([...ascending]);
|
||||
await header.click();
|
||||
await expect(cells).toHaveText([...ascending].reverse());
|
||||
await header.click();
|
||||
await expect(cells).toHaveText([...values]);
|
||||
});
|
||||
}
|
||||
|
||||
test("precise filter values are ordered and filtering composes with result sorting", async ({ workbench: page }) => {
|
||||
await seedResult(page, [...integers], "BIGINT");
|
||||
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
|
||||
await grid.getByRole("button", { name: "Filter value", exact: true }).click();
|
||||
const values = page.getByRole("group", { name: "Values", exact: true });
|
||||
const ascending = [integers[3]!, integers[2]!, integers[1]!, integers[0]!];
|
||||
await expect(values.locator("label")).toHaveText(ascending);
|
||||
await values.getByRole("checkbox", { name: integers[3], exact: true }).uncheck();
|
||||
await page.keyboard.press("Escape");
|
||||
await grid
|
||||
.getByRole("columnheader", { name: /^value\b/ })
|
||||
.getByText("value", { exact: true })
|
||||
.click();
|
||||
await expect(grid.getByRole("gridcell")).toHaveText(ascending.slice(1));
|
||||
});
|
||||
|
||||
test("selection aggregates preserve exact minimum and maximum values", async ({ workbench: page }) => {
|
||||
await seedResult(page, [...decimals], "NUMERIC(38,19)");
|
||||
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
|
||||
await grid.getByRole("gridcell").first().click();
|
||||
await grid
|
||||
.getByRole("gridcell")
|
||||
.last()
|
||||
.click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "Show Record / Value / Aggregates", exact: true }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Value panel", exact: true });
|
||||
await panel.getByRole("tab", { name: "Aggregates", exact: true }).click();
|
||||
await expect(panel.getByRole("row", { name: `Min ${decimals[2]}`, exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("row", { name: `Max ${decimals[0]}`, exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
const edgeTitle = "Result 3 — a long query result at the edge of the Services pane";
|
||||
const bindings = { "alpha-console.sql": "alpha", "beta-console.sql": "beta" };
|
||||
@@ -64,7 +64,9 @@ function menu(page: Page) {
|
||||
|
||||
async function choose(page: Page, title: string, command: string) {
|
||||
await tab(page, title).click({ button: "right" });
|
||||
await menu(page).getByRole("menuitem", { name: command, exact: true }).click();
|
||||
await menu(page)
|
||||
.getByRole("menuitem", { name: command.startsWith("Select ") ? new RegExp(`^${command}`) : command, exact: true })
|
||||
.click();
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -90,9 +92,11 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
|
||||
"Close",
|
||||
"Close Others",
|
||||
"Close All",
|
||||
"Close All but Pinned",
|
||||
"Pin Tab",
|
||||
"Rename Tab",
|
||||
"Select Next Tab",
|
||||
"Select Previous Tab",
|
||||
"Select Next Tab⇧⌘]",
|
||||
"Select Previous Tab⇧⌘[",
|
||||
]);
|
||||
await expect(popup.getByRole("separator")).toHaveCount(2);
|
||||
await selected(page, "Result 2", "beta rows");
|
||||
@@ -140,7 +144,7 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(popup.getByRole("menuitem", { name: "Close", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(popup.getByRole("menuitem", { name: "Select Previous Tab", exact: true })).toBeFocused();
|
||||
await expect(popup.getByRole("menuitem", { name: /^Select Previous Tab/ })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(popup).toHaveCount(0);
|
||||
await selected(page, "Result 1", "alpha rows");
|
||||
@@ -170,6 +174,8 @@ for (const [theme, label] of [
|
||||
const surface = target.locator("..");
|
||||
const other = tab(page, "Result 3");
|
||||
const otherSurface = other.locator("..");
|
||||
// The results hold focus, as they do at the end when the menu is opened from a result tab.
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.mouse.move(0, 0);
|
||||
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const selectedColor = await tab(page, "Result 2")
|
||||
@@ -207,7 +213,9 @@ for (const [theme, label] of [
|
||||
await expect(surface).toHaveCSS("background-color", idle);
|
||||
await expect(otherSurface).toHaveCSS("background-color", hovered);
|
||||
await selected(page, "Result 2", "beta rows");
|
||||
await menu(page).getByRole("menuitem", { name: "Select Previous Tab", exact: true }).click();
|
||||
await menu(page)
|
||||
.getByRole("menuitem", { name: /^Select Previous Tab/ })
|
||||
.click();
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(otherSurface).toHaveCSS("background-color", idle);
|
||||
@@ -221,6 +229,43 @@ for (const [theme, label] of [
|
||||
});
|
||||
}
|
||||
|
||||
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const token = (name: string) =>
|
||||
page.evaluate((name) => {
|
||||
const probe = document.body.appendChild(document.createElement("div"));
|
||||
probe.style.background = `var(${name})`;
|
||||
const color = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, name);
|
||||
const accent = await token("--bg-tab-selected");
|
||||
const inactive = await token("--bg-tab-selected-inactive");
|
||||
expect(inactive).not.toBe(accent);
|
||||
const surfaces = [
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
|
||||
page.getByRole("tab", { name: "Database", exact: true }),
|
||||
tab(page, "Result 2"),
|
||||
].map((tab) => tab.locator(".."));
|
||||
const colors = () =>
|
||||
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
|
||||
|
||||
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
// A menu opened from a result tab keeps the results active.
|
||||
await tab(page, "Result 2").click({ button: "right" });
|
||||
await expect(menu(page)).toBeVisible();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
|
||||
await page.locator(".cm-content").first().click();
|
||||
await expect.poll(colors).toEqual([accent, inactive, inactive]);
|
||||
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
|
||||
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
|
||||
});
|
||||
|
||||
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
@@ -266,6 +311,59 @@ test("renames the clicked inactive result without changing its rows and navigate
|
||||
await selected(page, "Result 3", "third rows");
|
||||
});
|
||||
|
||||
test("default tab shortcuts stay in results across repeated tab and grid switches, and remain editor shortcuts elsewhere", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
await openFromTree(page, "settings.gradle.kts");
|
||||
const editors = page.getByRole("tablist", { name: "Editor tabs", exact: true });
|
||||
const build = editors.getByRole("tab", { name: "build.gradle.kts", exact: true });
|
||||
const settings = editors.getByRole("tab", { name: "settings.gradle.kts", exact: true });
|
||||
await seedResults(page);
|
||||
|
||||
await tab(page, "Result 1").focus();
|
||||
for (const [shortcut, title, value] of [
|
||||
["Meta+Shift+BracketRight", "Result 3", "third rows"],
|
||||
["Meta+Shift+BracketRight", "Output"],
|
||||
["Meta+Shift+BracketRight", "Result 1", "alpha rows"],
|
||||
["Meta+Shift+BracketLeft", "Output"],
|
||||
["Meta+Shift+BracketLeft", "Result 3", "third rows"],
|
||||
]) {
|
||||
await page.keyboard.press(shortcut!);
|
||||
await selected(page, title!, value);
|
||||
await expect(settings).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
// The grid is replaced on a tab switch; the second press must still belong to results.
|
||||
await page.getByRole("gridcell", { name: "third rows", exact: true }).click();
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await selected(page, "Output");
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await selected(page, "Result 1", "alpha rows");
|
||||
await page.getByRole("gridcell", { name: "alpha rows", exact: true }).click();
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await selected(page, "Result 2", "beta rows");
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await selected(page, "Result 3", "third rows");
|
||||
await expect(settings).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await settings.click();
|
||||
await page.locator(".cm-content[contenteditable=true]").first().click();
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await expect(build).toHaveAttribute("aria-selected", "true");
|
||||
await selected(page, "Result 3", "third rows");
|
||||
await page.keyboard.press("Meta+Shift+BracketLeft");
|
||||
await expect(settings).toHaveAttribute("aria-selected", "true");
|
||||
await selected(page, "Result 3", "third rows");
|
||||
|
||||
await tab(page, "Result 1").click({ button: "right" });
|
||||
await page.keyboard.press("Meta+Shift+BracketRight");
|
||||
await page.keyboard.press("Meta+Shift+BracketLeft");
|
||||
await selected(page, "Result 3", "third rows");
|
||||
await expect(menu(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("rename owns deletion and Cmd+Enter without editing the previously focused query", async ({ workbench: page }) => {
|
||||
await seedResults(page);
|
||||
await page.evaluate(async () => {
|
||||
@@ -356,3 +454,148 @@ test("closes clicked results, other results and all results without removing Out
|
||||
await expect(page.getByRole("treeitem", { name: "Alpha database", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: "Beta database", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
/** Runs `text` in console `path` through the production store, with the backend's answers stubbed. */
|
||||
async function runConsole(page: Page, path: string, text: string) {
|
||||
await page.evaluate(
|
||||
async ({ path, text }) => {
|
||||
const bindingsPath = "/src/bindings.ts";
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const { commands } = await import(bindingsPath);
|
||||
const { executeConsole } = await import(storePath);
|
||||
commands.dbStatements = async (_id: string, sql: string) => {
|
||||
let from = 0;
|
||||
const data = sql.split(";").flatMap((part) => {
|
||||
const start = from + part.length - part.trimStart().length;
|
||||
from += part.length + 1;
|
||||
const st = part.trim();
|
||||
return st ? [{ from: start, to: start + st.length, text: st }] : [];
|
||||
});
|
||||
return { status: "ok", data };
|
||||
};
|
||||
commands.dbExecute = async (_r: unknown, _id: string, statements: { text: string }[]) => ({
|
||||
status: "ok",
|
||||
data: statements.map((statement) => ({
|
||||
statement,
|
||||
error: null,
|
||||
result: {
|
||||
statement: statement.text,
|
||||
columns: [{ name: "payload", typeName: "text" }],
|
||||
rows: [[/'(.*)'/.exec(statement.text)![1]]],
|
||||
affected: null,
|
||||
hasMore: false,
|
||||
elapsedMs: 1,
|
||||
executionMs: 1,
|
||||
},
|
||||
})),
|
||||
});
|
||||
await executeConsole(path, text, 0, text.length);
|
||||
},
|
||||
{ path, text },
|
||||
);
|
||||
}
|
||||
|
||||
test("console runs replace their unpinned results; pinned results stay until Close All but Pinned", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await seedResults(page);
|
||||
const strip = page.getByRole("tablist", { name: "Results", exact: true }).getByRole("tab");
|
||||
await choose(page, "Result 1", "Close All");
|
||||
// The seeded Output line's id would collide with the ids of the lines the runs log.
|
||||
await page.evaluate(async () => {
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const { useDatabase } = await import(storePath);
|
||||
useDatabase.setState({ output: [] });
|
||||
});
|
||||
await runConsole(page, "alpha-console.sql", "SELECT 'one'");
|
||||
const n = Number(/\d+/.exec((await strip.nth(1).textContent())!)![0]);
|
||||
const result = (run: number, index = 1) => `Result ${n + run}${index > 1 ? `-${index}` : ""}`;
|
||||
await runConsole(page, "alpha-console.sql", "SELECT 'two'");
|
||||
await expect(strip).toHaveText(["Output", result(1)]);
|
||||
await selected(page, result(1), "two");
|
||||
|
||||
const pin = page.getByRole("button", { name: "Pin Tab", exact: true });
|
||||
await pin.click();
|
||||
await expect(pin).toHaveAttribute("aria-pressed", "true");
|
||||
await runConsole(page, "alpha-console.sql", "SELECT 'three'; SELECT 'four'");
|
||||
await runConsole(page, "beta-console.sql", "SELECT 'beta'");
|
||||
await expect(strip).toHaveText(["Output", result(1), result(2), result(2, 2), result(3)]);
|
||||
await selected(page, result(3), "beta");
|
||||
|
||||
await tab(page, result(2)).click({ button: "right" });
|
||||
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeEnabled();
|
||||
await menu(page).getByRole("menuitem", { name: "Pin Tab", exact: true }).click();
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
await runConsole(page, "alpha-console.sql", "SELECT 'five'");
|
||||
await expect(strip).toHaveText(["Output", result(1), result(2), result(4), result(3)]);
|
||||
await selected(page, result(4), "five");
|
||||
await expect(pin).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await tab(page, result(2)).click();
|
||||
await expect(pin).toHaveAttribute("aria-pressed", "true");
|
||||
await choose(page, result(3), "Close All but Pinned");
|
||||
await expect(strip).toHaveText(["Output", result(1), result(2)]);
|
||||
await selected(page, result(2), "three");
|
||||
await tab(page, result(1)).click({ button: "right" });
|
||||
await expect(menu(page).getByRole("menuitem", { name: "Unpin Tab", exact: true })).toBeVisible();
|
||||
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// A pinned tab's close button is its Unpin button; middle-click leaves it open.
|
||||
const slot = tab(page, result(1)).locator("..");
|
||||
await expect(slot.getByRole("button", { name: "Close Tab", exact: true })).toHaveCount(0);
|
||||
await tab(page, result(1)).click({ button: "middle" });
|
||||
await expect(strip).toHaveText(["Output", result(1), result(2)]);
|
||||
await slot.getByRole("button", { name: "Unpin Tab", exact: true }).click();
|
||||
await selected(page, result(2), "three");
|
||||
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
|
||||
await expect(strip).toHaveText(["Output", result(2)]);
|
||||
});
|
||||
|
||||
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const grid = page.getByRole("grid");
|
||||
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
|
||||
await expect(cell).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await expect(cell).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(tab(page, "Result 2")).toBeFocused();
|
||||
// The toolbar is entered at the control used last.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
|
||||
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).not.toBeFocused();
|
||||
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
|
||||
|
||||
// The keyboard path to the header's local filter is the Local Filter action.
|
||||
await grid.focus();
|
||||
await page.evaluate(async () => {
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("db.localFilter");
|
||||
});
|
||||
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
|
||||
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filter).toBeHidden();
|
||||
await expect(grid).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test.use({ video: "on" });
|
||||
|
||||
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/dev/acme-api/README.md";
|
||||
const probe = new Promise((resolve) => {
|
||||
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
|
||||
});
|
||||
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
|
||||
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
|
||||
const keymap = "/src/platform/keymap.ts";
|
||||
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
|
||||
const actions = "/src/platform/actions.ts";
|
||||
await (
|
||||
await import(/* @vite-ignore */ actions)
|
||||
).executeAction("diff.compareTexts", {
|
||||
path,
|
||||
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
|
||||
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
|
||||
});
|
||||
});
|
||||
|
||||
const pane = page.locator(".cm-merge-b .cm-content");
|
||||
await pane.click();
|
||||
await expect(pane).toBeFocused();
|
||||
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
|
||||
await expect(jump).toHaveCount(0);
|
||||
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
|
||||
await expect(jump).toBeVisible();
|
||||
await expect(pane).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Meta+ArrowDown");
|
||||
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
|
||||
await expect(pane).toBeHidden();
|
||||
});
|
||||
@@ -122,6 +122,23 @@ test("Apply writes and stages unresolved text only after confirmation", async ({
|
||||
expect((await writes(page))[0]!.args[2]).toEqual({ changes: [{ from: 0, to: 13, insert: BASE }] });
|
||||
});
|
||||
|
||||
test("footer buttons work when a click doesn't focus them, as in WebKit", async ({ page }) => {
|
||||
await mountMerge(page);
|
||||
await result(page).locator(".cm-content").click();
|
||||
// WebKit focuses the clicked button's nearest focusable ancestor, not the button.
|
||||
await page
|
||||
.locator("#merge-host")
|
||||
.getByRole("button", { name: "Apply", exact: true })
|
||||
.evaluate((button) => {
|
||||
let target = button.parentElement;
|
||||
while (target && !target.hasAttribute("tabindex")) target = target.parentElement;
|
||||
if (target) target.focus();
|
||||
else (document.activeElement as HTMLElement | null)?.blur();
|
||||
(button as HTMLButtonElement).click();
|
||||
});
|
||||
await expect(page.getByRole("dialog")).toContainText("Save and mark the file as resolved anyway?");
|
||||
});
|
||||
|
||||
test("binary and unreadable versions show an error with no Apply controls", async ({ page }) => {
|
||||
await mountMerge(page, ["\0base", "\0ours", "\0theirs"]);
|
||||
await expect(page.locator("#merge-host")).toContainText("Binary files cannot be resolved in the merge editor.");
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.13.0
|
||||
Bundle-Version: 1.14.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
Binary file not shown.
@@ -17,6 +17,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.archives.RefreshArchivesCommand">
|
||||
<command id="openidea.java.refreshArchives"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ai.openidea.archives;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IPath;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.core.runtime.Path;
|
||||
import org.eclipse.jdt.core.IJavaModel;
|
||||
import org.eclipse.jdt.core.IJavaProject;
|
||||
import org.eclipse.jdt.core.IPackageFragmentRoot;
|
||||
import org.eclipse.jdt.core.JavaCore;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
|
||||
/**
|
||||
* Re-reads library archives replaced in place (a SNAPSHOT installed again keeps its path). The
|
||||
* arguments are the archives' file system paths. Their package fragment roots are closed, which
|
||||
* drops the class files read from the old archive, and jdtls' decompiled sources (cached by class
|
||||
* handle, which a replaced archive keeps) are dropped, so the classes are read again.
|
||||
*/
|
||||
public final class RefreshArchivesCommand implements IDelegateCommandHandler {
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
IJavaModel model = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot());
|
||||
model.refreshExternalArchives(null, monitor);
|
||||
Set<IPath> archives = new HashSet<>();
|
||||
for (Object argument : arguments) {
|
||||
if (argument instanceof String path) archives.add(Path.fromOSString(path));
|
||||
}
|
||||
int closed = 0;
|
||||
if (archives.isEmpty()) return closed;
|
||||
clearDecompilerCache();
|
||||
for (IJavaProject project : model.getJavaProjects()) {
|
||||
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
if (root.isArchive() && root.isExternal() && archives.contains(root.getPath()) && root.isOpen()) {
|
||||
root.close();
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** jdtls keeps decompiled sources in a static per-decompiler LRU with no API to drop them. */
|
||||
private static void clearDecompilerCache() {
|
||||
try {
|
||||
Class<?> impl = IDelegateCommandHandler.class.getClassLoader()
|
||||
.loadClass("org.eclipse.jdt.ls.core.internal.decompiler.DecompilerImpl");
|
||||
Field field = impl.getDeclaredField("decompilerCache");
|
||||
field.setAccessible(true);
|
||||
if (field.get(null) instanceof Map<?, ?> caches) {
|
||||
synchronized (caches) {
|
||||
for (Object cache : caches.values()) {
|
||||
if (cache instanceof Map<?, ?> map) map.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
JavaLanguageServerPlugin.logException("Clearing decompiled sources", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.core.search.IJavaSearchScope;
|
||||
import org.eclipse.jdt.core.search.SearchEngine;
|
||||
import org.eclipse.jdt.internal.core.CreateTypeHierarchyOperation;
|
||||
import org.eclipse.jdt.internal.core.DefaultWorkingCopyOwner;
|
||||
import org.eclipse.jdt.internal.core.JavaModelManager;
|
||||
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
@@ -56,7 +61,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
|
||||
return false;
|
||||
}
|
||||
if (type == null || type.getJavaProject() == null) return false;
|
||||
IRegion sources = JavaCore.newRegion();
|
||||
List<IJavaElement> sources = new ArrayList<>();
|
||||
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
|
||||
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
|
||||
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
|
||||
@@ -64,7 +69,13 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
|
||||
// IType.newTypeHierarchy's operation over the sources: the index narrows the subtypes to the
|
||||
// files that name a supertype, where a region hierarchy resolves every type in the region.
|
||||
ICompilationUnit[] workingCopies = JavaModelManager.getJavaModelManager().getWorkingCopies(DefaultWorkingCopyOwner.PRIMARY, true);
|
||||
IJavaSearchScope scope = SearchEngine.createJavaSearchScope(sources.toArray(IJavaElement[]::new));
|
||||
CreateTypeHierarchyOperation operation = new CreateTypeHierarchyOperation(type, workingCopies, scope, true);
|
||||
operation.runOperation(monitor);
|
||||
ITypeHierarchy hierarchy = operation.getResult();
|
||||
for (IType subtype : hierarchy.getAllSubtypes(type)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
IMember found;
|
||||
|
||||
@@ -72,6 +72,21 @@ public class Task implements Runnable {
|
||||
public void run() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Anon.java': '''package zoo;
|
||||
|
||||
public class Anon {
|
||||
Repo<Double> make() {
|
||||
class Local implements Repo<Double> {
|
||||
public void save(Double item) {
|
||||
}
|
||||
}
|
||||
return new Repo<Double>() {
|
||||
public void save(Double item) {
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
@@ -174,11 +189,11 @@ try:
|
||||
request('java/buildWorkspace', True)
|
||||
# (implementations in the sources, whether they are all there are)
|
||||
cases = {
|
||||
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
|
||||
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
|
||||
# Classes, abstract, local and anonymous ones too, and those of the bridged Kotlin output; not the sub-interface.
|
||||
('Repo.java', 'Repo<T>'): (['Anon.java:4', 'Anon.java:8', 'Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
|
||||
# Through the type arguments; not the abstract re-declaration.
|
||||
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Repo.java', 'save(T'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Use.java', 'save("x")'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Repo.java', 'name()'): (['Words.java:6'], True),
|
||||
# A library type's implementations in the sources; the library has more.
|
||||
('Use.java', 'Runnable task'): (['Task.java:2'], False),
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
|
||||
d6991b9065d8b623efdf9c03fb92b4875e95e666b3dadb3925ff5ad327f5fdcb
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Git Toolbox for OpenIDEA: background fetch of every open project's
|
||||
//! repository (on the plugin's `on-schedule`, which also fires after the
|
||||
//! instance was reclaimed while idle), and the focused project's branch with
|
||||
//! instance was reclaimed while idle), and the focused file's branch with
|
||||
//! its ahead/behind counts in the status bar.
|
||||
|
||||
use openidea_plugin::host::{self, storage};
|
||||
@@ -14,7 +14,12 @@ const LAST_FETCH: &str = "last-fetch:";
|
||||
const SHOWN: &str = "shown-root";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GitToolbox;
|
||||
pub struct GitToolbox {
|
||||
/// The focused editor's file path, from the last `editor-focused` event.
|
||||
/// Kept across events: the status bar keeps following that file's
|
||||
/// repository until another file takes focus.
|
||||
focused: Option<String>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
@@ -67,31 +72,51 @@ fn tooltip(s: &RepositoryStatus) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Shows `project`'s repository in the status bar (or hides the item).
|
||||
fn show(project: &Project) -> Result<(), Error> {
|
||||
match project.vcs_status()? {
|
||||
Some(s) => {
|
||||
/// Shows the first of `projects` that has a repository in the status bar (the
|
||||
/// item hides when none has). Returns the index of the one shown.
|
||||
fn show(projects: &[Project]) -> Result<Option<usize>, Error> {
|
||||
for (i, project) in projects.iter().enumerate() {
|
||||
if let Some(s) = project.vcs_status()? {
|
||||
let _ = storage::set(SHOWN, &project.root()?);
|
||||
host::set_status_item(
|
||||
ITEM,
|
||||
&StatusItem { text: label(&s), tooltip: Some(tooltip(&s)), command: Some("fetch".into()) },
|
||||
);
|
||||
return Ok(Some(i));
|
||||
}
|
||||
None => host::remove_status_item(ITEM),
|
||||
}
|
||||
Ok(())
|
||||
host::remove_status_item(ITEM);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The project the status bar follows: the invoking window's, else the one
|
||||
/// shown last, else the first open one.
|
||||
fn current(ctx: &Invocation) -> Option<Project> {
|
||||
if let Some(p) = ctx.project() {
|
||||
return Some(p);
|
||||
impl GitToolbox {
|
||||
/// The projects the status bar may follow, most specific first: the
|
||||
/// focused file's repository (a workspace window's project is the
|
||||
/// container folder, and its focused files belong to member
|
||||
/// repositories), the invoking window's, the one shown last, else the
|
||||
/// first open one.
|
||||
fn candidates(&self, ctx: &Invocation) -> Vec<Project> {
|
||||
let mut out: Vec<Project> = Vec::new();
|
||||
let editor = ctx.editor().map(|e| e.path());
|
||||
let mut push = |project: Option<Project>| {
|
||||
if let Some(p) = project
|
||||
&& let Ok(root) = p.root()
|
||||
&& out.iter().all(|q| q.root() != Ok(root.clone()))
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
push(self.focused.as_deref().or(editor.as_deref()).and_then(host::project_of));
|
||||
push(ctx.project());
|
||||
let projects = host::projects().unwrap_or_default();
|
||||
let shown = storage::get(SHOWN).ok().flatten();
|
||||
let index = projects.iter().position(|p| p.root().ok().as_deref() == shown.as_deref()).unwrap_or(0);
|
||||
let mut rest = projects;
|
||||
if index < rest.len() {
|
||||
push(Some(rest.swap_remove(index)));
|
||||
}
|
||||
out
|
||||
}
|
||||
let projects = host::projects().ok()?;
|
||||
let shown = storage::get(SHOWN).ok().flatten();
|
||||
let index = projects.iter().position(|p| p.root().ok() == shown).unwrap_or(0);
|
||||
projects.into_iter().nth(index)
|
||||
}
|
||||
|
||||
/// Fetches `projects` in one cancellable task. Returns how many were fetched.
|
||||
@@ -131,15 +156,22 @@ fn fetch(projects: &[Project]) -> Result<usize, Error> {
|
||||
|
||||
impl Plugin for GitToolbox {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
let project = current(ctx).ok_or("no project is open")?;
|
||||
let projects = self.candidates(ctx);
|
||||
if projects.is_empty() {
|
||||
return Err("no project is open".into());
|
||||
}
|
||||
match id {
|
||||
"fetch" => {
|
||||
let n = fetch(std::slice::from_ref(&project))?;
|
||||
show(&project)?;
|
||||
// Only the repository the status bar shows, not every candidate.
|
||||
let Some(i) = show(&projects)? else {
|
||||
return Ok(Some("The project is not in a git repository.".to_owned()));
|
||||
};
|
||||
let n = fetch(std::slice::from_ref(&projects[i]))?;
|
||||
show(&projects)?;
|
||||
Ok((n == 0).then(|| "The project is not in a git repository.".to_owned()))
|
||||
}
|
||||
"refresh" => {
|
||||
show(&project)?;
|
||||
show(&projects)?;
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
@@ -155,9 +187,12 @@ impl Plugin for GitToolbox {
|
||||
host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(p) = current(ctx) {
|
||||
let _ = show(&p);
|
||||
for e in events {
|
||||
if let Event::EditorFocused(Some(path)) = e {
|
||||
self.focused = Some(path.clone());
|
||||
}
|
||||
}
|
||||
let _ = show(&self.candidates(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +230,7 @@ mod tests {
|
||||
let host = MockHost::install();
|
||||
host.add_project("/other");
|
||||
host.set_vcs_status(Some(status(0, 4)));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.fetches(), 2, "every open project");
|
||||
let item = &host.status_items()["branch"];
|
||||
assert_eq!(item.text, "main ↓4");
|
||||
@@ -208,26 +243,72 @@ mod tests {
|
||||
#[test]
|
||||
fn focus_changes_only_refresh() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = GitToolbox::default();
|
||||
host.set_vcs_status(Some(status(1, 0)));
|
||||
GitToolbox.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.fetches(), 0);
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↑1");
|
||||
host.set_vcs_status(None);
|
||||
GitToolbox.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
|
||||
plugin.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
|
||||
assert!(host.status_items().is_empty(), "not a repository any more");
|
||||
}
|
||||
|
||||
/// A workspace window's project is the container folder, which is no
|
||||
/// repository: the focused file's member repository is shown instead, and
|
||||
/// follows the focus between members.
|
||||
#[test]
|
||||
fn workspace_windows_follow_the_focused_members_repository() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = GitToolbox::default();
|
||||
host.set_project_root(Some("/workspace"));
|
||||
host.add_project("/repos/a");
|
||||
host.add_project("/repos/b");
|
||||
host.set_vcs_status(Some(status(1, 2)));
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/repos/a/src/main.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/a", "the focused file's member, not the container");
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↑1 ↓2");
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/repos/b/src/lib.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/b", "the focus moved to the other member");
|
||||
// A scheduled tick keeps following the focused member.
|
||||
plugin.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/b");
|
||||
}
|
||||
|
||||
/// Fetch Now fetches the repository the status bar shows, not every
|
||||
/// other open project.
|
||||
#[test]
|
||||
fn fetch_now_fetches_only_the_shown_repository() {
|
||||
let host = MockHost::install();
|
||||
host.add_project("/other");
|
||||
storage::set(SHOWN, "/other").unwrap();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
GitToolbox::default().run_command("fetch", &host.invocation(), None).unwrap();
|
||||
assert_eq!(host.fetches(), 1);
|
||||
assert_eq!(host.storage()["shown-root"], "/project");
|
||||
}
|
||||
|
||||
/// A focused path outside every open project falls back to the window's
|
||||
/// project, whose repository is shown as before.
|
||||
#[test]
|
||||
fn an_unresolvable_focused_path_falls_back_to_the_window_project() {
|
||||
let host = MockHost::install();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
GitToolbox::default().handle_events(&[Event::EditorFocused(Some("src/main.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/project");
|
||||
assert_eq!(host.status_items()["branch"].text, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_and_cancellation() {
|
||||
let host = MockHost::install();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
host.fail_fetch(Some(Error::Failed("no network".into())));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.notifications()[0].body, "no network");
|
||||
let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err();
|
||||
let err = GitToolbox::default().run_command("fetch", &host.invocation(), None).unwrap_err();
|
||||
assert_eq!(err, Error::Failed("no network".into()));
|
||||
host.fail_fetch(Some(Error::Cancelled));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report");
|
||||
}
|
||||
|
||||
@@ -235,7 +316,7 @@ mod tests {
|
||||
fn without_vcs_write_it_still_shows_the_branch() {
|
||||
let host = MockHost::install().with_capabilities(&["project.read", "vcs.read", "storage"]);
|
||||
host.set_vcs_status(Some(status(0, 1)));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert!(host.notifications()[0].body.contains("vcs.write"));
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↓1");
|
||||
}
|
||||
|
||||
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -262,6 +264,7 @@ from `type`, then map:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -189,22 +189,44 @@ pub fn settings_get_global(state: State<'_, AppState>) -> Settings {
|
||||
#[specta::specta]
|
||||
pub fn settings_set(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
scope: SettingsScope,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> IdeResult<Settings> {
|
||||
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?;
|
||||
let window = window.label().to_owned();
|
||||
// The merge validates its result by deserializing the whole `Settings`, which needs
|
||||
// ~250 KiB of stack in a debug build (measured by bisecting a thread's stack size; the
|
||||
// same with and without a settings file, so it is the type, not the data). A WebView2
|
||||
// IPC callback reaches this command with ~750 KiB of the main thread's 1 MiB already
|
||||
// used, so the merge — and anything else called from that callback — can overflow it.
|
||||
// A worker thread starts from a clean stack, where the default 2 MiB is ample.
|
||||
std::thread::spawn(move || {
|
||||
let state = app.state::<AppState>();
|
||||
settings_set_now(&state, &window, scope, &key, &value)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| IdeError::internal("settings worker panicked"))?
|
||||
}
|
||||
|
||||
fn settings_set_now(
|
||||
state: &AppState,
|
||||
window: &str,
|
||||
scope: SettingsScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> IdeResult<Settings> {
|
||||
let applied = ide_host::ops::project::set_settings(&state.host, window, scope, key, value)?;
|
||||
if key == "mcpServer" || key.starts_with("mcpServer.") {
|
||||
crate::commands::mcp::apply(&state);
|
||||
crate::commands::mcp::apply(state);
|
||||
}
|
||||
if !applied.project_scope {
|
||||
state.updates.wake.notify_one();
|
||||
}
|
||||
// A project value concerns only that project's window (and its settings window).
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key };
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key: key.to_owned() };
|
||||
if applied.project_scope {
|
||||
let owner = AppState::owner_window(window.label());
|
||||
let owner = AppState::owner_window(window);
|
||||
for label in [owner.to_owned(), crate::windows::settings_label(owner)] {
|
||||
let sink = crate::events::TauriSink { app: state.app.clone(), window: Some(label) };
|
||||
sink.emit(event.clone());
|
||||
@@ -227,6 +249,7 @@ pub async fn settings_export(
|
||||
path: PathBuf,
|
||||
storage: std::collections::BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::agent::move_user_mcp_values(&state.host);
|
||||
state.global_settings.export_bundle(&path, storage)
|
||||
}
|
||||
|
||||
@@ -244,6 +267,7 @@ pub struct SettingsImport {
|
||||
#[specta::specta]
|
||||
pub async fn settings_import(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<SettingsImport> {
|
||||
let (_, storage) = state.global_settings.import_bundle(&path)?;
|
||||
ide_host::ops::agent::move_user_mcp_values(&state.host);
|
||||
for project in state.projects() {
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
|
||||
@@ -158,6 +158,7 @@ pub async fn deploy_test_connection(
|
||||
state: State<'_, AppState>,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await
|
||||
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path, password).await
|
||||
}
|
||||
@@ -15,45 +15,92 @@ use tauri::{State, Window};
|
||||
#[derive(Default)]
|
||||
pub struct McpService {
|
||||
server: Mutex<Option<ide_mcp::McpServer>>,
|
||||
/// The port and token settings the running server was started with.
|
||||
running_with: Mutex<Option<Listen>>,
|
||||
error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// Starts or stops the server to match the global `mcpServer.enabled`.
|
||||
/// What the port and token settings ask the server for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Listen {
|
||||
/// `mcpServer.port` with `mcpServer.fixedPort`; otherwise a free port.
|
||||
port: Option<u16>,
|
||||
fixed_token: bool,
|
||||
}
|
||||
|
||||
/// Starts, stops or restarts the server to match the global `mcpServer` settings: `enabled`,
|
||||
/// and the port and token it listens with.
|
||||
pub fn apply(state: &AppState) {
|
||||
let enabled = state.global_settings.effective().mcp_server.enabled;
|
||||
let settings = state.global_settings.effective().mcp_server;
|
||||
let mut server = state.mcp.server.lock();
|
||||
if !enabled {
|
||||
if !settings.enabled {
|
||||
if let Some(running) = server.take() {
|
||||
running.stop();
|
||||
}
|
||||
*state.mcp.running_with.lock() = None;
|
||||
ide_agent::set_mcp_executable(None);
|
||||
state.host.set_mcp_executable(None);
|
||||
*state.mcp.error.lock() = None;
|
||||
return;
|
||||
}
|
||||
if server.is_some() {
|
||||
let wanted = Listen {
|
||||
port: settings.fixed_port.then_some(settings.port).filter(|p| *p != 0),
|
||||
fixed_token: settings.fixed_token,
|
||||
};
|
||||
if server.is_some() && *state.mcp.running_with.lock() == Some(wanted) {
|
||||
return;
|
||||
}
|
||||
// Another port or token: clients connect anew.
|
||||
if let Some(running) = server.take() {
|
||||
running.stop();
|
||||
if let Some(port) = wanted.port {
|
||||
wait_until_free(port);
|
||||
}
|
||||
}
|
||||
let token = if wanted.fixed_token {
|
||||
match ide_mcp::kept_token(&ide_mcp::token_path(&state.data_dir)) {
|
||||
Ok(token) => Some(token),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: cannot keep the token ({e}); using a new one for this run");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let discovery = ide_mcp::discovery_path(&state.data_dir);
|
||||
match ide_mcp::McpServer::start(
|
||||
match ide_mcp::McpServer::start_with(
|
||||
Arc::new(ide_host::mcp::Registry::new(state.host.clone())),
|
||||
Some(discovery),
|
||||
&state.host.runtime,
|
||||
ide_mcp::ServerOptions { port: wanted.port, token },
|
||||
) {
|
||||
Ok(started) => {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
state.host.set_mcp_executable(Some(exe.clone()));
|
||||
ide_agent::set_mcp_executable(Some(exe));
|
||||
*server = Some(started);
|
||||
*state.mcp.running_with.lock() = Some(wanted);
|
||||
*state.mcp.error.lock() = None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: {e}");
|
||||
*state.mcp.running_with.lock() = None;
|
||||
*state.mcp.error.lock() = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
|
||||
fn wait_until_free(port: u16) {
|
||||
for _ in 0..100 {
|
||||
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the server on exit (its discovery file goes with it).
|
||||
pub fn stop(state: &AppState) {
|
||||
state.host.set_mcp_executable(None);
|
||||
@@ -61,6 +108,7 @@ pub fn stop(state: &AppState) {
|
||||
if let Some(server) = state.mcp.server.lock().take() {
|
||||
server.stop();
|
||||
}
|
||||
*state.mcp.running_with.lock() = None;
|
||||
}
|
||||
|
||||
/// What Settings › Agents › MCP Server shows.
|
||||
@@ -71,11 +119,14 @@ pub struct McpServerStatus {
|
||||
/// Why it isn't running though turned on.
|
||||
pub error: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
/// Why the fixed port (`mcpServer.port`) isn't the one listened on: it was in use or
|
||||
/// reserved, so a free port was taken for this run.
|
||||
pub port_unavailable: Option<String>,
|
||||
/// The window's local project, which the URL and command below are for.
|
||||
pub project: Option<IpcPath>,
|
||||
/// `http://127.0.0.1:<port>/mcp?project=<root>`.
|
||||
pub url: Option<String>,
|
||||
/// Bearer token of `url` (changes with every run).
|
||||
/// Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on).
|
||||
pub token: Option<String>,
|
||||
/// The stdio bridge: this executable and its arguments.
|
||||
pub stdio_command: Vec<String>,
|
||||
@@ -91,6 +142,7 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
|
||||
running: server.is_some(),
|
||||
error: state.mcp.error.lock().clone(),
|
||||
port: server.as_ref().map(|s| s.port()),
|
||||
port_unavailable: server.as_ref().and_then(|s| s.port_unavailable().map(str::to_owned)),
|
||||
url: server.as_ref().zip(root.as_ref()).map(|(s, r)| s.url(r)),
|
||||
token: server.as_ref().map(|s| s.token().to_owned()),
|
||||
stdio_command: root.as_ref().map(|r| ide_mcp::stdio_command(&exe, r)).unwrap_or_default(),
|
||||
@@ -98,6 +150,21 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
|
||||
})
|
||||
}
|
||||
|
||||
/// Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
|
||||
/// server with a new one. Clients configured with the old token stop working.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_regenerate_token(state: State<'_, AppState>) -> IdeResult<()> {
|
||||
match std::fs::remove_file(ide_mcp::token_path(&state.data_dir)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
*state.mcp.running_with.lock() = None;
|
||||
apply(&state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn mcp_set_open_editors(window: Window, state: State<'_, AppState>, paths: Vec<IpcPath>) -> IdeResult<()> {
|
||||
|
||||
@@ -75,6 +75,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
mcp::mcp_ui_ready,
|
||||
mcp::mcp_respond,
|
||||
mcp::mcp_agent_run_closed,
|
||||
mcp::mcp_regenerate_token,
|
||||
mcp::mcp_server_status,
|
||||
mcp::mcp_set_open_editors,
|
||||
mcp::mcp_set_focused_editor,
|
||||
@@ -675,6 +676,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
wsl::wsl_resolve,
|
||||
ssh::ssh_set_secret,
|
||||
ssh::ssh_needs_secret,
|
||||
ssh::ssh_password_saved,
|
||||
ssh::ssh_trust_host,
|
||||
ssh::ssh_connected,
|
||||
ssh::ssh_disconnect,
|
||||
|
||||
@@ -27,6 +27,13 @@ pub fn ssh_set_secret(state: State<'_, AppState>, id: String, secret: Option<Str
|
||||
state.ssh.set_secret(&id, secret, save);
|
||||
}
|
||||
|
||||
/// Whether a password is saved under `key` (an FTP deployment server's).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn ssh_password_saved(state: State<'_, AppState>, key: String) -> bool {
|
||||
state.ssh.password_saved(&key)
|
||||
}
|
||||
|
||||
/// Whether connecting with configuration `id` needs a password first.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -63,7 +63,9 @@ impl EventSink for TauriSink {
|
||||
) {
|
||||
flash_taskbar_unless_focused(&self.app);
|
||||
}
|
||||
if matches!(&event, IdeEvent::McpRequest { .. })
|
||||
// A confirmation waits for the user: bring the window up. Other requests (an agent's
|
||||
// run, opening a file) need nothing from the user and leave the focus where it is.
|
||||
if matches!(&event, IdeEvent::McpRequest { request: ide_core::event::McpUiRequest::Confirm { .. }, .. })
|
||||
&& let Some(label) = self.window.clone()
|
||||
{
|
||||
let app = self.app.clone();
|
||||
|
||||
@@ -180,6 +180,29 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
|
||||
|
||||
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
|
||||
|
||||
/// Escape the page leaves unhandled reaches the window as `cancelOperation:`, on which NSWindow
|
||||
/// leaves full screen, so pressing Escape once more after closing a popup left full screen. The
|
||||
/// IDE's windows answer it with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves it.
|
||||
pub fn keep_full_screen_on_escape() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| unsafe {
|
||||
let Some(class) = AnyClass::get(c"TaoWindow") else {
|
||||
tracing::warn!("no TaoWindow class: Escape leaves full screen");
|
||||
return;
|
||||
};
|
||||
let imp: Imp = std::mem::transmute(ignore_cancel_operation as CancelOperation);
|
||||
let class = class as *const AnyClass as *mut AnyClass;
|
||||
let added = objc2::ffi::class_addMethod(class, objc2::sel!(cancelOperation:), imp, c"v@:@".as_ptr());
|
||||
if !added.as_bool() {
|
||||
tracing::warn!("could not handle cancelOperation:");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type CancelOperation = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
|
||||
|
||||
unsafe extern "C-unwind" fn ignore_cancel_operation(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) {}
|
||||
|
||||
/// Whether AppKit is handling a key press right now. A menu item performed at this point
|
||||
/// was chosen by its key equivalent: a click, arrow keys plus Return and accessibility
|
||||
/// presses all run with a non-key current event.
|
||||
|
||||
@@ -660,6 +660,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
decorate_csd(&window);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::page_swipe::install(&window);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos::keep_full_screen_on_escape();
|
||||
apply_zoom(app, &window);
|
||||
#[cfg(target_os = "macos")]
|
||||
align_titlebar(app, &window, app.try_state::<WindowsState>().is_some_and(|w| w.opens_project(window.label())));
|
||||
|
||||
+45
-4
@@ -19,6 +19,11 @@ export const commands = {
|
||||
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
|
||||
/** The user closed the Run tool window tab of an agent command (`run_command`). */
|
||||
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
|
||||
/**
|
||||
* Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
|
||||
* server with a new one. Clients configured with the old token stop working.
|
||||
*/
|
||||
mcpRegenerateToken: () => typedError<null, IdeError>(__TAURI_INVOKE("mcp_regenerate_token")),
|
||||
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
|
||||
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
|
||||
mcpSetFocusedEditor: (editor: {
|
||||
@@ -1589,6 +1594,8 @@ export const commands = {
|
||||
sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE<void>("ssh_set_secret", { id, secret, save }),
|
||||
/** Whether connecting with configuration `id` needs a password first. */
|
||||
sshNeedsSecret: (id: string) => typedError<boolean, IdeError>(__TAURI_INVOKE("ssh_needs_secret", { id })),
|
||||
/** Whether a password is saved under `key` (an FTP deployment server's). */
|
||||
sshPasswordSaved: (key: string) => __TAURI_INVOKE<boolean>("ssh_password_saved", { key }),
|
||||
/** Trusts the host key that failed the last connection of configuration `id`. */
|
||||
sshTrustHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("ssh_trust_host", { id })),
|
||||
/** Ids of the configurations with an open connection. */
|
||||
@@ -1618,7 +1625,7 @@ export const commands = {
|
||||
} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })),
|
||||
deployServerRoot: (server: string) => typedError<DeploymentRoot, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
|
||||
remoteList: (connection: FsConnection, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { connection, path })),
|
||||
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
|
||||
deployTestConnection: (connection: FsConnection, path: string, password: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path, password })),
|
||||
remoteOpen: (connection: FsConnection, path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("remote_open", { connection, path })),
|
||||
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
|
||||
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
|
||||
@@ -2497,7 +2504,7 @@ export type AskpassKind =
|
||||
/** Yes / no (an unknown host key); answered with `yes` or `no`. */
|
||||
"confirm";
|
||||
|
||||
export type AttachKind = "java" | "node" | "python" | "go" | "native";
|
||||
export type AttachKind = "java" | "node" | "python" | "dotnet" | "go" | "native";
|
||||
|
||||
export type AttachableProcess = {
|
||||
pid: number,
|
||||
@@ -4085,6 +4092,14 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4128,6 +4143,8 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
@@ -5859,7 +5876,12 @@ workspaceEdit?: boolean } |
|
||||
/** A document's dirty flag changed (saved / modified externally). */
|
||||
{ type: "docDirty"; doc: DocId; dirty: boolean } |
|
||||
/** A complete workspace edit can be undone as one operation. */
|
||||
{ type: "workspaceEditApplied"; id: number; paths: IpcPath[] } |
|
||||
{ type: "workspaceEditApplied"; id: number; paths: IpcPath[];
|
||||
/**
|
||||
* Made by an agent through the MCP server's tools: undoable like a refactoring, but
|
||||
* not announced as one.
|
||||
*/
|
||||
byAgent: boolean } |
|
||||
/**
|
||||
* An open document's language changed (Settings › File Types, Override File
|
||||
* Type), or its UI mode (a framework's root marker appeared or went).
|
||||
@@ -7197,6 +7219,18 @@ export type McpServerSettings = {
|
||||
agentTabLimit?: number,
|
||||
/** Archived agent tabs kept; older ones are closed. */
|
||||
agentArchiveLimit?: number,
|
||||
/**
|
||||
* Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
* port forwards keep working after a restart.
|
||||
*/
|
||||
fixedPort?: boolean,
|
||||
/** The port of [`Self::fixed_port`]. */
|
||||
port?: number,
|
||||
/**
|
||||
* Keep the bearer token across runs (in the app data, readable by this user only) rather than
|
||||
* make a new one each run.
|
||||
*/
|
||||
fixedToken?: boolean,
|
||||
};
|
||||
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
@@ -7205,11 +7239,16 @@ export type McpServerStatus = {
|
||||
/** Why it isn't running though turned on. */
|
||||
error: string | null,
|
||||
port: number | null,
|
||||
/**
|
||||
* Why the fixed port (`mcpServer.port`) isn't the one listened on: it was in use or
|
||||
* reserved, so a free port was taken for this run.
|
||||
*/
|
||||
portUnavailable: string | null,
|
||||
/** The window's local project, which the URL and command below are for. */
|
||||
project: IpcPath | null,
|
||||
/** `http://127.0.0.1:<port>/mcp?project=<root>`. */
|
||||
url: string | null,
|
||||
/** Bearer token of `url` (changes with every run). */
|
||||
/** Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on). */
|
||||
token: string | null,
|
||||
/** The stdio bridge: this executable and its arguments. */
|
||||
stdioCommand: string[],
|
||||
@@ -9568,6 +9607,8 @@ export type SmartKeysSettings = {
|
||||
pairQuotes?: boolean,
|
||||
surroundSelection?: boolean,
|
||||
tabOutOfPair?: boolean,
|
||||
/** Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag. */
|
||||
simultaneousTagEditing?: boolean,
|
||||
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
|
||||
reformatOnPaste?: string,
|
||||
escapeOnPaste?: boolean,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerEditorKind, type EditorKindContribution } from "@/workbench/contributions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import type * as Messages from "@/workbench/messages";
|
||||
import { EditorController, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof Messages>()),
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
|
||||
let controller: EditorController | null = null;
|
||||
afterEach(() => {
|
||||
controller?.stop();
|
||||
controller = null;
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
useProject.setState({ project: null, settings: null });
|
||||
vi.mocked(confirm).mockReset();
|
||||
});
|
||||
|
||||
/** A kind whose tabs answer `isBusy`; the title is the input key. */
|
||||
const busyKind = (isBusy: EditorKindContribution["isBusy"]) =>
|
||||
registerEditorKind({ kind: "busy-probe", title: (i) => i.key, component: () => null, isBusy });
|
||||
|
||||
const start = () => {
|
||||
controller = new EditorController(createMemoryBackend({}));
|
||||
controller.start();
|
||||
return controller;
|
||||
};
|
||||
const pane = () => useTabs.getState().panes[useTabs.getState().activePane]!;
|
||||
const keys = () => pane().tabs.map((tab) => tab.input.key);
|
||||
const question = (name: string) => ({
|
||||
title: t("editor.closeBusy.title", { name }),
|
||||
message: t("editor.closeBusy.message", { name }),
|
||||
ok: t("editor.closeBusy.terminate"),
|
||||
});
|
||||
|
||||
describe("editor kinds' isBusy on close", () => {
|
||||
it("asks before closing a busy tab and keeps it on Cancel", async () => {
|
||||
const dispose = busyKind(() => true);
|
||||
vi.mocked(confirm).mockResolvedValue(false);
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
expect(keys()).toEqual(["busy-probe:1"]);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the busy tab once terminating is confirmed", async () => {
|
||||
const closed = vi.fn();
|
||||
const dispose = registerEditorKind({
|
||||
kind: "busy-probe",
|
||||
title: (i) => i.key,
|
||||
component: () => null,
|
||||
isBusy: () => true,
|
||||
onClose: closed,
|
||||
});
|
||||
vi.mocked(confirm).mockResolvedValue(true);
|
||||
const ctrl = start();
|
||||
const input = { kind: "busy-probe", key: "busy-probe:1" };
|
||||
try {
|
||||
await ctrl.openInput(input);
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(true);
|
||||
expect(keys()).toEqual([]);
|
||||
expect(closed).toHaveBeenCalledExactlyOnceWith(input);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("awaits an async answer, and closes a tab that is not busy without asking", async () => {
|
||||
const dispose = busyKind(async (i) => i.key === "busy-probe:1");
|
||||
vi.mocked(confirm).mockResolvedValue(false);
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
const idle = pane().tabs.find((tab) => tab.input.key === "busy-probe:2")!;
|
||||
expect(await ctrl.closeTab(pane().id, idle.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a failing isBusy as not busy, so the tab still closes", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const dispose = busyKind(() => {
|
||||
throw new Error("probe failed");
|
||||
});
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
dispose();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks only when the last tab of a busy input closes", async () => {
|
||||
const dispose = busyKind(() => true);
|
||||
vi.mocked(confirm).mockResolvedValue(true);
|
||||
const ctrl = start();
|
||||
const input = { kind: "busy-probe", key: "busy-probe:1" };
|
||||
try {
|
||||
await ctrl.openInput(input);
|
||||
ctrl.split(useTabs.getState().activePane, "row");
|
||||
const panes = Object.values(useTabs.getState().panes);
|
||||
// Another pane still shows the input: nothing ends, nothing to ask.
|
||||
expect(await ctrl.closeTab(panes[0]!.id, panes[0]!.tabs[0]!.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(await ctrl.closeInput(input.key)).toBe(true);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor kinds' isBusy on tab-limit eviction", () => {
|
||||
const limitOne = () =>
|
||||
useProject.setState({ settings: { editor: { tabs: { tabLimit: 1, closingPolicy: "lru" } } } as never });
|
||||
|
||||
it.each([
|
||||
["a sync answer", (key: string) => key === "busy-probe:1"],
|
||||
["an async answer", (key: string) => Promise.resolve(key === "busy-probe:1")],
|
||||
])("skips a busy tab without asking (%s)", async (_name, busy) => {
|
||||
const dispose = busyKind((i) => busy(i.key));
|
||||
const ctrl = start();
|
||||
limitOne();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:3" });
|
||||
// The least recently used tab is the busy one: it stays, the idle one makes room instead.
|
||||
expect(keys()).toEqual(["busy-probe:1", "busy-probe:3"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts the least recently used tab when none is busy", async () => {
|
||||
const dispose = busyKind(() => false);
|
||||
const ctrl = start();
|
||||
limitOne();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:3" });
|
||||
expect(keys()).toEqual(["busy-probe:3"]);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { undo, history } from "@codemirror/commands";
|
||||
import { enterInDocComment, phpDocTags } from "./docComment";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { enterInBlockComment, enterInDocComment, phpDocTags } from "./docComment";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
@@ -75,3 +76,41 @@ describe("Enter after /**", () => {
|
||||
expect(enter("/**|", "python", hash).handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Enter through `enterInBlockComment`, in a parsed Java document. */
|
||||
function enterBlock(text: string) {
|
||||
const at = text.indexOf("|");
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text.replace("|", ""),
|
||||
selection: EditorSelection.cursor(at),
|
||||
extensions: [java()],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
const handled = enterInBlockComment(view);
|
||||
const s = view.state;
|
||||
const caret = s.selection.main.head;
|
||||
return { handled, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
|
||||
}
|
||||
|
||||
describe("Enter in a /* */ comment", () => {
|
||||
it("starts the new line with a leading asterisk aligned under the comment's", () => {
|
||||
expect(enterBlock("/**\n * xxxx|\n **/\nclass A {}").text).toBe("/**\n * xxxx\n * |\n **/\nclass A {}");
|
||||
expect(enterBlock("class A {\n /**\n * a|b\n */\n}").text).toBe(
|
||||
"class A {\n /**\n * a\n * |b\n */\n}",
|
||||
);
|
||||
expect(enterBlock("class A {\n /* note|\n */\n}").text).toBe("class A {\n /* note\n * |\n */\n}");
|
||||
});
|
||||
|
||||
it("keeps the closing */ on its own line", () => {
|
||||
expect(enterBlock("/** summary |*/\nclass A {}").text).toBe("/** summary\n * |\n */\nclass A {}");
|
||||
});
|
||||
|
||||
it("leaves Enter alone outside a comment, before its asterisk, and in a string", () => {
|
||||
expect(enterBlock("/* a */ int x;|").handled).toBe(false);
|
||||
expect(enterBlock("/**\n| * a\n */").handled).toBe(false);
|
||||
expect(enterBlock('class A { String s = "/* x|"; }').handled).toBe(false);
|
||||
expect(enterBlock("// a /* b|").handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
|
||||
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
|
||||
*/
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { EditorSelection, type EditorState } from "@codemirror/state";
|
||||
import type { Command } from "@codemirror/view";
|
||||
|
||||
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
|
||||
@@ -86,3 +87,60 @@ export function enterInDocComment(language: string | undefined): Command {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** How far back the opening `/*` of the comment around the caret is looked for. */
|
||||
const OPENER_SCAN = 20_000;
|
||||
|
||||
/** Whether `pos` is inside a `/* *\/` comment opened at `open` (by the syntax tree, when it has one there). */
|
||||
function inBlockComment(state: EditorState, pos: number): number | null {
|
||||
const from = Math.max(0, pos - OPENER_SCAN);
|
||||
const before = state.sliceDoc(from, pos);
|
||||
const open = before.lastIndexOf("/*");
|
||||
if (open < 0 || before.indexOf("*/", open + 2) >= 0) return null;
|
||||
const node = syntaxTree(state).resolveInner(pos, -1);
|
||||
// A parsed document says whether this `/*` opens the comment (not text in a string or a `//`).
|
||||
if (node.parent && !(/comment/i.test(node.name) && node.from === from + open)) return null;
|
||||
return from + open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter inside a `/* *\/` comment, on its first line or on a line that starts with `*`: the new
|
||||
* line starts with `* ` aligned under the comment's asterisks, as IDEA's leading asterisks do.
|
||||
*/
|
||||
export const enterInBlockComment: Command = (view) => {
|
||||
const { state } = view;
|
||||
if (state.readOnly || state.selection.ranges.length !== 1) return false;
|
||||
const r = state.selection.main;
|
||||
if (!r.empty) return false;
|
||||
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
|
||||
if (block?.open !== "/*" || block.close !== "*/") return false;
|
||||
const open = inBlockComment(state, r.head);
|
||||
if (open === null) return false;
|
||||
const line = state.doc.lineAt(r.head);
|
||||
const before = line.text.slice(0, r.head - line.from);
|
||||
const after = line.text.slice(r.head - line.from);
|
||||
let prefix: string;
|
||||
if (open >= line.from) {
|
||||
// The opening line: the asterisks go one column right of the `/`.
|
||||
prefix = line.text.slice(0, open - line.from).replace(/[^\t]/g, " ") + " ";
|
||||
} else {
|
||||
const star = /^([ \t]*)\*(?!\/)/.exec(line.text);
|
||||
if (!star || star[0].length > before.length) return false;
|
||||
prefix = star[1]!;
|
||||
}
|
||||
const br = state.lineBreak;
|
||||
const rest = after.trimStart();
|
||||
const insert = rest.startsWith("*/") ? `${br}${prefix}* ${br}${prefix}` : `${br}${prefix}* `;
|
||||
// Spaces around the break go, as a plain Enter drops them.
|
||||
const from = r.head - (before.length - before.trimEnd().length);
|
||||
const caret = from + br.length + prefix.length + 2;
|
||||
view.dispatch(
|
||||
state.update({
|
||||
changes: { from, to: r.head + (after.length - rest.length), insert },
|
||||
selection: EditorSelection.cursor(caret),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { history, redo, undo } from "@codemirror/commands";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { prefsExtension } from "./prefs";
|
||||
import { angular } from "./angular";
|
||||
import { astro } from "./astro";
|
||||
import { django } from "./django";
|
||||
import { jsp } from "./jsp";
|
||||
import { vueLanguage } from "./vue";
|
||||
import { validateEditorExtras } from "@/platform/editorSettings";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
const extensions: Extension = [html(), linkedTags];
|
||||
|
||||
/** `|` marks carets, `[`/`]` a selection. */
|
||||
function make(text: string, extra: Extension = []) {
|
||||
return makeWith([EditorState.allowMultipleSelections.of(true), extensions, extra], text);
|
||||
}
|
||||
|
||||
/** Like `make`, with the full extension list spelled out. */
|
||||
function makeWith(extensions: Extension, text: string) {
|
||||
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [];
|
||||
let doc = "";
|
||||
let anchor = -1;
|
||||
for (const ch of text) {
|
||||
if (ch === "|") ranges.push(EditorSelection.cursor(doc.length));
|
||||
else if (ch === "[") anchor = doc.length;
|
||||
else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length));
|
||||
else doc += ch;
|
||||
}
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
|
||||
extensions,
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function show(view: EditorView): string {
|
||||
const { doc, selection } = view.state;
|
||||
let out = doc.toString();
|
||||
for (const r of [...selection.ranges].sort((a, b) => b.from - a.from))
|
||||
out = r.empty
|
||||
? out.slice(0, r.head) + "|" + out.slice(r.head)
|
||||
: out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Types `text` at every caret / over every selection, as typing does. */
|
||||
function type(view: EditorView, text: string) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: r.from, to: r.to, insert: text },
|
||||
range: EditorSelection.cursor(r.from + text.length),
|
||||
})),
|
||||
{ userEvent: "input.type" },
|
||||
);
|
||||
}
|
||||
|
||||
/** One Backspace before every caret. */
|
||||
function backspace(view: EditorView) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: Math.max(0, r.head - 1), to: r.head },
|
||||
range: EditorSelection.cursor(Math.max(0, r.head - 1)),
|
||||
})),
|
||||
{ userEvent: "delete.backward" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("linked html tags", () => {
|
||||
it("renames the closing tag while the opening one is typed", () => {
|
||||
const v = make('<div| class="container">\n Content\n</div>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
// The caret stays where the user typed, not in the mirrored tag.
|
||||
expect(show(v)).toBe('<division| class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames the opening tag while the closing one is typed", () => {
|
||||
const v = make('<div class="container">\n Content\n</div|>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames a name typed at its start", () => {
|
||||
const v = make("<|div></div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<sdiv></sdiv>");
|
||||
});
|
||||
|
||||
it("follows deletions", () => {
|
||||
const v = make("<div|></div>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<di></di>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<d></d>");
|
||||
});
|
||||
|
||||
it("follows a selection replaced with a new name", () => {
|
||||
const v = make("<[div]>x</div>");
|
||||
type(v, "article");
|
||||
expect(v.state.doc.toString()).toBe("<article>x</article>");
|
||||
expect(show(v)).toBe("<article|>x</article>");
|
||||
});
|
||||
|
||||
it("pairs the outer tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div>inner</div>\n</div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs>\n <div>inner</div>\n</divs>");
|
||||
});
|
||||
|
||||
it("pairs the inner tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div|>inner</div>\n</div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<div>\n <divs>inner</divs>\n</div>");
|
||||
});
|
||||
|
||||
it("renames two elements with one caret each", () => {
|
||||
const v = make("<p|>one</p>\n<h2|>two</h2>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one</px>\n<h2x>two</h2x>");
|
||||
});
|
||||
|
||||
it("leaves both tags alone when the caret edits both at once", () => {
|
||||
const v = make("<div|></div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("mirrors only the caret inside a tag name", () => {
|
||||
const v = make("<p|>one\nplain| text\n</p>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one\nplainx text\n</px>");
|
||||
});
|
||||
|
||||
it("skips rather than guesses with two carets in one name", () => {
|
||||
const v = make("<di|v|></div>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<dixvx></div>");
|
||||
});
|
||||
|
||||
it("does not touch other tags or attributes", () => {
|
||||
const v = make('<div class="container" |>\n <span>keep</span>\n</div>');
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe('<div class="container" x>\n <span>keep</span>\n</div>');
|
||||
});
|
||||
|
||||
it("skips void and self-closing tags", () => {
|
||||
const v = make("<p>before</p>\n<img|>\n<br>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<p>before</p>\n<imgx>\n<br>");
|
||||
const selfClosing = make("<p>before</p>\n<div|/>");
|
||||
type(selfClosing, "x");
|
||||
expect(selfClosing.state.doc.toString()).toBe("<p>before</p>\n<divx/>");
|
||||
});
|
||||
|
||||
it("skips unclosed elements and stray close tags", () => {
|
||||
const v = make("<div|>unclosed");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx>unclosed");
|
||||
const stray = make("<p>before</p>\n</div|>");
|
||||
type(stray, "x");
|
||||
expect(stray.state.doc.toString()).toBe("<p>before</p>\n</divx>");
|
||||
});
|
||||
|
||||
it("keeps a partially typed tag unmirrored", () => {
|
||||
const v = make("<di|v");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<disv");
|
||||
});
|
||||
|
||||
it("does not follow structural characters typed at the name's edge", () => {
|
||||
// `/` would make the open tag self-closing; `>` would end it: neither extends the name.
|
||||
const v = make("<div|></div>");
|
||||
type(v, "/");
|
||||
expect(v.state.doc.toString()).toBe("<div/></div>");
|
||||
const closing = make("<div>content</di|v>");
|
||||
type(closing, ">");
|
||||
expect(closing.state.doc.toString()).toBe("<div>content</di>v>");
|
||||
});
|
||||
|
||||
it("undoes the pair as one step and redoes it", () => {
|
||||
const v = make("<div|></div>", history());
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
expect(undo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<div></div>");
|
||||
expect(redo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
});
|
||||
|
||||
// Every mode whose outer parser is `@lezer/html`: renaming an opening tag follows into
|
||||
// the closing one (prefs.ts enables the filter for exactly these language ids).
|
||||
describe("in the html-based modes", () => {
|
||||
const modes: [string, () => Extension][] = [
|
||||
["Vue", () => vueLanguage()],
|
||||
["Angular Template", () => angular()],
|
||||
["JSP", () => jsp()],
|
||||
["Django Template", () => django()],
|
||||
["Astro", () => astro()],
|
||||
];
|
||||
|
||||
for (const [name, language] of modes) {
|
||||
it(`renames the closing tag while the opening one is typed in ${name}`, () => {
|
||||
const v = makeWith([language(), linkedTags], '<div| class="a">x</div>');
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe('<divs class="a">x</divs>');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the smart key gates the filter", () => {
|
||||
/** The real wiring: prefsExtension attaches the filter for the document's language. */
|
||||
const prefs = (language: string | undefined, smartKeys: Record<string, unknown> = {}) =>
|
||||
makeWith(
|
||||
[
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
// A parseable tree: the real editors attach the mode's parser beside prefsExtension.
|
||||
html(),
|
||||
prefsExtension({
|
||||
prefs: validateEditorExtras({ smartKeys }),
|
||||
path: "",
|
||||
softWrap: false,
|
||||
showWhitespace: false,
|
||||
hardWrapAt: 120,
|
||||
readOnly: false,
|
||||
language,
|
||||
}),
|
||||
],
|
||||
"<div|></div>",
|
||||
);
|
||||
|
||||
it("links tags for plain html", () => {
|
||||
const v = prefs("html");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("links tags for the html-based language ids", () => {
|
||||
for (const language of ["vue", "jsp", "django-html", "astro"]) {
|
||||
const v = prefs(language);
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
}
|
||||
});
|
||||
|
||||
it("stays off when the option is off", () => {
|
||||
const v = prefs("html", { simultaneousTagEditing: false });
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
|
||||
it("stays off for the token-based erb mode", () => {
|
||||
const v = prefs("erb");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Linked editing for matching HTML tags, as in IDEA's Simultaneous
|
||||
* `<tag></tag>` editing (on by default) and VS Code's Linked Editing: editing
|
||||
* the name in an opening (or closing) tag renames the matching tag of the same
|
||||
* element as you type. Done from the syntax tree — `@lezer/html`'s
|
||||
* `OpenTag`/`CloseTag` elements, shared by every HTML-based mode — so it works
|
||||
* without a language server.
|
||||
*
|
||||
* A transaction whose change lies inside one tag's `TagName` is extended with
|
||||
* the same edit at the same offset in the counterpart's name — one
|
||||
* transaction, so undo takes both back together. Without a matching tag (void
|
||||
* and self-closing tags, unclosed elements, a stray close tag) or with the two
|
||||
* names already different (malformed markup) nothing follows: the tags stay as
|
||||
* they are.
|
||||
*/
|
||||
import { EditorState, type Extension, type Transaction } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { SyntaxNode, Tree } from "@lezer/common";
|
||||
import { isExternalChange } from "@/editor/api";
|
||||
|
||||
/** The `TagName` a changed range edits — an insertion at either edge extends the name. */
|
||||
function tagNameAround(tree: Tree, from: number, to: number): SyntaxNode | null {
|
||||
let n = tree.resolveInner(from, -1);
|
||||
if (n.name !== "TagName") n = tree.resolveInner(from, 1);
|
||||
return n.name === "TagName" && n.from <= from && to <= n.to ? n : null;
|
||||
}
|
||||
|
||||
/** The matching tag's name of the same element, or null without one. */
|
||||
function counterpartName(name: SyntaxNode): SyntaxNode | null {
|
||||
const tag = name.parent;
|
||||
const element = tag?.parent;
|
||||
if (!tag || element?.name !== "Element") return null;
|
||||
const other = element.getChild(
|
||||
/^Open/.test(tag.name) ? tag.name.replace("Open", "Close") : tag.name.replace("Close", "Open"),
|
||||
);
|
||||
return other?.getChild("TagName") ?? null;
|
||||
}
|
||||
|
||||
/** Insertions at a name's edge must extend the name; structural characters end it. */
|
||||
const NOT_NAME_CHARS = /[\s/>"'<=]/;
|
||||
|
||||
/**
|
||||
* Editing the name of an opening (or closing) HTML tag renames its matching
|
||||
* tag, from the syntax tree — no language server. Installed while Smart Keys ›
|
||||
* Simultaneous `<tag></tag>` editing is on (the default).
|
||||
*/
|
||||
export const linkedTags: Extension = EditorState.transactionFilter.of((tr: Transaction) => {
|
||||
const state = tr.startState;
|
||||
if (!tr.docChanged || state.readOnly || isExternalChange(tr)) return tr;
|
||||
const tree = syntaxTree(state);
|
||||
const edits: { from: number; to: number; insert: string }[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) =>
|
||||
edits.push({ from: fromA, to: toA, insert: inserted.toString() }),
|
||||
);
|
||||
const mirrored: { from: number; to: number; insert: string }[] = [];
|
||||
const targets = new Set<number>();
|
||||
for (const edit of edits) {
|
||||
const name = tagNameAround(tree, edit.from, edit.to);
|
||||
if (!name || NOT_NAME_CHARS.test(edit.insert)) continue;
|
||||
const other = counterpartName(name);
|
||||
// Names that already differ mean malformed markup: leave the tags alone.
|
||||
if (!other || state.sliceDoc(name.from, name.to) !== state.sliceDoc(other.from, other.to)) continue;
|
||||
// Both ends edited at once (a second caret, undo of a linked edit): already in step.
|
||||
if (edits.some((e) => e.from <= other.to && other.from <= e.to)) continue;
|
||||
// Two carets in one name: their offsets would not line up; skip rather than guess.
|
||||
if (targets.has(other.from)) return tr;
|
||||
targets.add(other.from);
|
||||
mirrored.push({
|
||||
from: other.from + (edit.from - name.from),
|
||||
to: other.from + (edit.to - name.from),
|
||||
insert: edit.insert,
|
||||
});
|
||||
}
|
||||
if (!mirrored.length) return tr;
|
||||
return [
|
||||
tr,
|
||||
{
|
||||
changes: mirrored
|
||||
.map((m) => ({ from: tr.changes.mapPos(m.from, -1), to: tr.changes.mapPos(m.to, 1), insert: m.insert }))
|
||||
.sort((a, b) => a.from - b.from),
|
||||
sequential: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -49,9 +49,10 @@ import {
|
||||
selectHumpRight,
|
||||
} from "./editing";
|
||||
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
|
||||
import { enterInDocComment } from "./docComment";
|
||||
import { enterInBlockComment, enterInDocComment } from "./docComment";
|
||||
import { renderDocComments } from "./renderedDocs";
|
||||
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { stringClipboard } from "./stringClipboard";
|
||||
import { filterCopiedText } from "./clipboard";
|
||||
import { LINE_INSET } from "./lineInset";
|
||||
@@ -480,6 +481,14 @@ export const GUTTER_ICON_GROUPS = [
|
||||
{ id: "colors", gutterClass: "cm-openidea-gutter-colors" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Languages whose outer parser is `@lezer/html`, where simultaneous
|
||||
* `<tag></tag>` editing works from the syntax tree. Angular templates carry
|
||||
* the `html` backend id (the Angular mode is picked per project), `erb` is a
|
||||
* token-based mode without a syntax tree and stays out.
|
||||
*/
|
||||
const LINKED_TAG_LANGUAGES = new Set(["html", "vue", "jsp", "django-html", "astro"]);
|
||||
|
||||
export interface PrefsOptions {
|
||||
prefs: EditorExtras;
|
||||
path: string;
|
||||
@@ -605,6 +614,11 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
|
||||
if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote));
|
||||
if (k.tabOutOfPair) out.push(tabOut);
|
||||
// Simultaneous `<tag></tag>` editing, from the syntax tree: every mode whose outer
|
||||
// parser is `@lezer/html` — plain HTML plus the HTML-based modes (Vue SFCs, Angular
|
||||
// templates, JSP, Django and Astro; Angular templates carry the `html` backend id).
|
||||
// ERB is token-based (no syntax tree), so it stays out.
|
||||
if (!o.readOnly && k.simultaneousTagEditing && LINKED_TAG_LANGUAGES.has(o.language ?? "")) out.push(linkedTags);
|
||||
if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle));
|
||||
|
||||
const keys = [];
|
||||
@@ -626,7 +640,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
|
||||
);
|
||||
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
|
||||
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
|
||||
if (!o.readOnly) keys.push({ key: "Enter", run: enterInBlockComment }, { key: "Enter", run: enterInLineComment });
|
||||
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
|
||||
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
|
||||
else if (smartBackspaceApplies(k.backspaceUnindent, o.language)) keys.push({ key: "Backspace", run: smartBackspace });
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
documentDirty,
|
||||
documentLifecycle,
|
||||
failures,
|
||||
inputBusy,
|
||||
inputDirty,
|
||||
type CustomDocument,
|
||||
} from "./model/customDocuments";
|
||||
@@ -1226,27 +1227,40 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs of kinds with an `isBusy` answer are never evicted (that would end a
|
||||
* running process unasked): their answers are resolved first, so the sync
|
||||
* and the async ones are protected alike.
|
||||
*/
|
||||
private enforcePaneTabLimit(paneId: string, keep?: string, liveKeys?: ReadonlySet<string>) {
|
||||
const prefs = editorPrefs().tabs;
|
||||
const protect = (tab: Tab) =>
|
||||
!!liveKeys?.has(tab.input.key) || inputDirty(tab.input, useContributions.getState().editorKinds) === true;
|
||||
return this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
prefs.tabLimit,
|
||||
prefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, useContributions.getState().editorKinds) ??
|
||||
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
keep ?? current.panes[paneId]?.active ?? undefined,
|
||||
protect,
|
||||
)
|
||||
: current,
|
||||
{ protect },
|
||||
);
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const busyCapable = (useTabs.getState().panes[paneId]?.tabs ?? []).filter((t) => kinds.get(t.input.kind)?.isBusy);
|
||||
const evict = (busy: ReadonlySet<string>) => {
|
||||
const prefs = editorPrefs().tabs;
|
||||
// A tab whose kind reports a running process is never evicted: that would end it unasked.
|
||||
const protect = (tab: Tab) =>
|
||||
busy.has(tab.input.key) || inputDirty(tab.input, kinds) === true || !!liveKeys?.has(tab.input.key);
|
||||
return this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
prefs.tabLimit,
|
||||
prefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, kinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
keep ?? current.panes[paneId]?.active ?? undefined,
|
||||
protect,
|
||||
)
|
||||
: current,
|
||||
{ protect },
|
||||
);
|
||||
};
|
||||
if (!busyCapable.length) return evict(new Set());
|
||||
return Promise.all(
|
||||
busyCapable.map(async (tab) => ((await inputBusy(tab.input, kinds)) ? tab.input.key : null)),
|
||||
).then((keys) => evict(new Set(keys.filter((key): key is string => key !== null))));
|
||||
}
|
||||
|
||||
activate(paneId: string, tabId: string, focus = true) {
|
||||
@@ -1316,6 +1330,30 @@ export class EditorController implements SlotHost {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs about to lose their last tab whose kind reports them busy
|
||||
* (`EditorKindContribution.isBusy`): asks before ending what runs in them,
|
||||
* like closing a busy terminal session. Resolves false to keep the tabs.
|
||||
*/
|
||||
private async confirmBusyClose(removal: (s: TabsState) => TabsState): Promise<boolean> {
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const current = useTabs.getState();
|
||||
const remaining = openKeys(removal(current));
|
||||
const closing = new Map<string, EditorInput>();
|
||||
for (const pane of Object.values(current.panes))
|
||||
for (const tab of pane.tabs)
|
||||
if (!remaining.has(tab.input.key) && kinds.get(tab.input.kind)?.isBusy) closing.set(tab.input.key, tab.input);
|
||||
const busy: string[] = [];
|
||||
for (const input of closing.values()) if (await inputBusy(input, kinds)) busy.push(tabTitle(input, kinds));
|
||||
if (!busy.length) return true;
|
||||
const name = busy.join(", ");
|
||||
return confirm({
|
||||
title: t("editor.closeBusy.title", { name }),
|
||||
message: t("editor.closeBusy.message", { name }),
|
||||
ok: t("editor.closeBusy.terminate"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Close exactly the originally selected tabs, evaluating references after every await. */
|
||||
private removeGuarded(
|
||||
select: (s: TabsState) => TabsState,
|
||||
@@ -1389,6 +1427,8 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
authorized.add(d.input.key);
|
||||
}
|
||||
if (!(await this.confirmBusyClose(currentRemoval))) return false;
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
if (options.beforeRemove && !(await options.beforeRemove())) return false;
|
||||
// Commit only the original target ids against the latest tab state.
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
|
||||
interface SourceState {
|
||||
@@ -82,6 +82,9 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project !== previous.project) clear();
|
||||
});
|
||||
// A reload may have replaced a library jar in place (a SNAPSHOT installed again): reopened
|
||||
// classes fetch their contents again (the backend refreshes the open ones).
|
||||
const offRoots = onIdeEvent("projectRootsChanged", () => invalidateVirtualDocuments());
|
||||
const offActions = registerActions([
|
||||
{
|
||||
id: "editor.downloadSources",
|
||||
@@ -118,6 +121,7 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
return () => {
|
||||
offActions();
|
||||
offProject();
|
||||
offRoots();
|
||||
clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,19 @@ export function inputDirty(input: EditorInput, kinds: Kinds): boolean | null {
|
||||
return lifecycle ? documentDirty({ input, lifecycle }) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the input's editor kind reports it busy (`EditorKindContribution.isBusy`).
|
||||
* A failing check counts as not busy, so a broken kind cannot make its tabs unclosable.
|
||||
*/
|
||||
export async function inputBusy(input: EditorInput, kinds: Kinds): Promise<boolean> {
|
||||
try {
|
||||
return (await kinds.get(input.kind)?.isBusy?.(input)) === true;
|
||||
} catch (e) {
|
||||
console.warn(`editor input ${input.key}: isBusy failed`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The rejection reasons of `results` (an AggregateError contributes each of its errors). */
|
||||
export function failures(results: readonly PromiseSettledResult<unknown>[]): unknown[] {
|
||||
return results.flatMap((r) =>
|
||||
|
||||
@@ -57,7 +57,12 @@ async function replace(text: string) {
|
||||
}
|
||||
async function rename() {
|
||||
await replace("new");
|
||||
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] });
|
||||
emitLocalIdeEvent({
|
||||
type: "workspaceEditApplied",
|
||||
id: 9,
|
||||
paths: ["/p/A.java", "/p/B.java", "/p/closed.java"],
|
||||
byAgent: false,
|
||||
});
|
||||
}
|
||||
function historyStep(slot: EditorSlot, forward = false) {
|
||||
return (forward ? redo : undo)({
|
||||
@@ -183,7 +188,7 @@ it("rebuilds multiple redo markers in order when a closed file opens", async ()
|
||||
const a = await open("/p/A.java");
|
||||
await rename();
|
||||
await replace("alt");
|
||||
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] });
|
||||
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"], byAgent: true });
|
||||
commands.undoWorkspaceEdit.mockImplementation(async (id: number) => {
|
||||
await replace(id === 10 ? "new" : "old");
|
||||
return { status: "ok", data: null };
|
||||
|
||||
@@ -367,6 +367,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
tabPrefs.placement === "right" && styles.tabsRight,
|
||||
)}
|
||||
data-pane-id={paneId}
|
||||
data-focus-scope=""
|
||||
aria-label={tx("Editor")}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
@@ -106,6 +106,12 @@ const lensField = StateField.define<CodeLens[]>({
|
||||
const usagesEnabled = () => editorPrefs().inlay.codeVisionUsages;
|
||||
const authorEnabled = () => editorPrefs().inlay.codeVisionAuthor;
|
||||
|
||||
/** The author hint opens the file's blame annotations, and leaves them open when shown. */
|
||||
function showAnnotations(view: EditorView) {
|
||||
view.focus();
|
||||
if (!getAction("vcs.annotate")?.toggled?.()) void executeAction("vcs.annotate");
|
||||
}
|
||||
|
||||
function openUsages(view: EditorView, offset: number) {
|
||||
view.dispatch({ selection: EditorSelection.cursor(offset), userEvent: "select.lens" });
|
||||
view.focus();
|
||||
@@ -258,6 +264,7 @@ class UsagesLensPlugin {
|
||||
icon: "user",
|
||||
order: 20,
|
||||
kind: "author",
|
||||
run: showAnnotations,
|
||||
});
|
||||
const n = usages ? this.cache.get(key(t)) : undefined;
|
||||
if (n === undefined || (n === 0 && !editorPrefs().inlay.codeVisionNoUsages)) continue;
|
||||
|
||||
@@ -235,12 +235,16 @@ const lintTheme = EditorView.theme({
|
||||
".cm-lintRange-error": {
|
||||
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
WebkitTextDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
fontWeight: "var(--wave-error-bold, inherit)",
|
||||
fontStyle: "var(--wave-error-italic, inherit)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
},
|
||||
".cm-lintRange-warning": {
|
||||
textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
|
||||
WebkitTextDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
|
||||
fontWeight: "var(--wave-warning-bold, inherit)",
|
||||
fontStyle: "var(--wave-warning-italic, inherit)",
|
||||
backgroundColor: "var(--editor-warning-background, transparent)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
@@ -254,8 +258,10 @@ const lintTheme = EditorView.theme({
|
||||
".cm-lintRange-hint": { textDecoration: "none" },
|
||||
// Typo level: IDEA's green wavy underline.
|
||||
".cm-lintRange.cm-lsp-typo": {
|
||||
textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
|
||||
WebkitTextDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
|
||||
textDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
|
||||
WebkitTextDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
|
||||
fontWeight: "var(--wave-typo-bold, inherit)",
|
||||
fontStyle: "var(--wave-typo-italic, inherit)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
},
|
||||
@@ -263,9 +269,16 @@ const lintTheme = EditorView.theme({
|
||||
textDecoration: "none",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--fg-unused, var(--fg-faint))",
|
||||
fontWeight: "var(--fg-unused-bold, inherit)",
|
||||
fontStyle: "var(--fg-unused-italic, inherit)",
|
||||
},
|
||||
".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" },
|
||||
".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" },
|
||||
".cm-lintRange.cm-lsp-deprecated": {
|
||||
textDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
|
||||
WebkitTextDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
|
||||
fontWeight: "var(--fg-deprecated-bold, inherit)",
|
||||
fontStyle: "var(--fg-deprecated-italic, inherit)",
|
||||
},
|
||||
// An error or warning on the same range (merged into one mark) keeps its underline.
|
||||
".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": {
|
||||
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user