Compare commits

..
Author SHA1 Message Date
l0ng-ai 4a667b2cd2 fix(vcs): lay out a merge commit's file side by side, each pane scrolling
Opening a file of a merge commit from the Git log stacked its three panes (parent 1, merge result, parent 2) in a column the editor could not scroll: only the first parent's text showed, without the changes, and the wheel and arrow keys did not move it. The panes now sit side by side under a label row, each one scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:28 +08:00
165 changed files with 362 additions and 5287 deletions

No files matched your search

+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}", cache-on-failure: true }
with: { key: "${{ matrix.target }}" }
- 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 }}", cache-on-failure: true }
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
Generated
-1
View File
@@ -4288,7 +4288,6 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+13 -4
View File
@@ -9,10 +9,6 @@
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>
&nbsp;·&nbsp;
@@ -23,6 +19,8 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -72,6 +70,17 @@ 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).
-88
View File
@@ -1,88 +0,0 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
</picture>
</p>
<p align="center">
你熟悉的 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>
&nbsp;·&nbsp;
<a href="https://openidea.sh/zh">文档</a>
&nbsp;·&nbsp;
<a href="#性能对比">性能对比</a>
&nbsp;·&nbsp;
<a href="#插件">插件</a>
&nbsp;·&nbsp;
<a href="#社区">社区</a>
&nbsp;·&nbsp;
<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 -43
View File
@@ -3,13 +3,11 @@
//!
//! 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, named in
//! `<home>/.codex/session_index.jsonl`).
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
@@ -208,39 +206,11 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
}
}
/// 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.
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
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);
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
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
}
#[cfg(test)]
@@ -312,16 +282,6 @@ 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]
-4
View File
@@ -1097,8 +1097,6 @@ 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();
@@ -1168,8 +1166,6 @@ 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,42 +158,6 @@ 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::*;
@@ -255,29 +219,6 @@ 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");
+10 -57
View File
@@ -191,8 +191,6 @@ struct State {
limit_resets_ms: Option<f64>,
terminal_only: Vec<String>,
titled: bool,
/// A title is being read or generated.
titling: bool,
}
struct Turn {
@@ -367,7 +365,6 @@ impl Shim {
limit_resets_ms: None,
terminal_only: Vec::new(),
titled: false,
titling: false,
}),
cwd,
starting: tokio::sync::Mutex::new(()),
@@ -586,12 +583,11 @@ 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. 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();
// The client went away: so do the processes.
let weak = self.me.clone();
tokio::spawn(async move {
client.closed().await;
if let Some(me) = me {
if let Some(me) = weak.upgrade() {
me.closed.store(true, Ordering::Release);
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
@@ -602,15 +598,6 @@ 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()
@@ -1061,8 +1048,7 @@ impl Session {
} else if matches!(st.start, Start::New { .. }) {
st.start = Start::Resume { id: self.id.clone(), at: None };
}
read_title = !st.titled && !st.titling;
st.titling |= read_title;
read_title = !st.titled;
if let Some(turn) = st.turn.as_ref()
&& belongs(turn, &msg)
{
@@ -1087,29 +1073,13 @@ impl Session {
}
}
/// 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.
/// Sends the title Claude Code gave the session, once it has one.
fn read_title(self: &Arc<Self>) {
let me = self.clone();
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()) {
tokio::task::spawn_blocking(move || {
let Some(title) = chain_title(&me) else { return };
me.st.lock().titled = true;
if let Some(shim) = me.shim() {
shim.notify(vec![Update {
session: me.id.clone(),
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
@@ -1118,19 +1088,6 @@ 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();
@@ -1460,13 +1417,9 @@ 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::read(&path).ok()
history::title(&history::read(&path).ok()?)
}
fn start_json(start: &Start) -> Value {
+4 -76
View File
@@ -311,8 +311,6 @@ 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 {
@@ -333,7 +331,6 @@ 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 {
@@ -424,18 +421,14 @@ impl Replayed {
}
}
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
Arc::new(Replay {
async fn replay(name: &str) -> Replayed {
let processes = load_fixture(name);
let cli = 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 =
@@ -468,69 +461,6 @@ 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;
@@ -797,7 +727,6 @@ 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;
@@ -841,7 +770,6 @@ 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 =
+4 -43
View File
@@ -28,9 +28,6 @@ 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,
@@ -129,10 +126,6 @@ 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 {
@@ -336,7 +329,6 @@ 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)
}
@@ -633,9 +625,8 @@ impl CodexShim {
Some(translate::plan(params))
}),
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
"thread/name/updated" => self.with_thread(thread, |t, _| {
"thread/name/updated" => self.with_thread(thread, |_, _| {
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)),
@@ -821,10 +812,9 @@ impl CodexShim {
}
}
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
fn turn_completed(&self, thread: &str, turn: &Value) {
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
let (parent, answer, name) = {
let (parent, answer) = {
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) {
@@ -840,15 +830,8 @@ impl CodexShim {
let _ = p.done.send((turn.clone(), error.clone()));
}
let answer = std::mem::take(&mut t.answer);
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)
(t.parent.clone(), (answer, error))
};
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) {
@@ -870,28 +853,6 @@ 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();
+5 -50
View File
@@ -535,10 +535,8 @@ 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 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 {
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
@@ -576,14 +574,7 @@ 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 (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 t = load(scenario.file());
let (ours, theirs) = tokio::io::duplex(1 << 20);
let (tr, tw) = tokio::io::split(theirs);
let client = Arc::new(Client::default());
@@ -599,8 +590,8 @@ async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<C
.await
.expect("the scenario finished");
// Codex stays up until the scenario is over.
let (sent, read, write) = player.await.unwrap();
(results, client, sent, meta, Box::new((acp, read, write)))
let (sent, _read, _write) = player.await.unwrap();
(results, client, sent, meta)
}
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
@@ -810,42 +801,6 @@ 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,16 +612,6 @@ 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 {
-21
View File
@@ -2391,27 +2391,6 @@ 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() });
-3
View File
@@ -270,8 +270,6 @@ 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,
@@ -291,7 +289,6 @@ 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,
-13
View File
@@ -77,8 +77,6 @@ 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,
@@ -94,7 +92,6 @@ 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,
@@ -106,16 +103,6 @@ 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 {
-3
View File
@@ -28,9 +28,6 @@ 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"] }
+12 -195
View File
@@ -1,7 +1,6 @@
//! 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), .NET (netcoredbg), Go binaries (Delve) and anything else
//! natively (LLDB).
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
use crate::adapter;
use ide_core::{IdeError, IdeResult};
@@ -15,7 +14,6 @@ pub enum AttachKind {
Java,
Node,
Python,
Dotnet,
Go,
Native,
}
@@ -97,29 +95,13 @@ 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_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> {
/// 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> {
let name = file_name(&p.exe).to_owned();
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
let (host, port) = match addr {
@@ -147,9 +129,6 @@ pub fn classify(
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);
}
@@ -164,7 +143,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)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
return false;
}
let Ok(file) = std::fs::File::open(exe) else { return false };
@@ -186,17 +165,6 @@ 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>> {
@@ -213,7 +181,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 as started on macOS; on Linux it is truncated, so read /proc.
// `comm` is the executable path 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();
@@ -222,8 +190,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.filter_map(split)
.filter(|(pid, _)| *pid != me)
.map(|(pid, args)| {
let exe = exe_path(pid)
.or_else(|| exes.get(&pid).cloned())
let exe = 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 }
@@ -231,134 +200,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.collect())
}
/// 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)]
#[cfg(not(unix))]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
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
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
}
/// Node's default inspector port, which `SIGUSR1` opens.
@@ -400,7 +244,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_dotnet, is_go_binary, native)).collect();
list_processes()?.iter().filter_map(|p| classify(p, 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)
}
@@ -414,8 +258,7 @@ mod tests {
}
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
.map(|p| (p.kind, p.port))
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
}
#[test]
@@ -449,37 +292,11 @@ 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()));
-11
View File
@@ -557,17 +557,6 @@ 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,
-1
View File
@@ -180,7 +180,6 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
+7 -103
View File
@@ -2,7 +2,6 @@
//! 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::{
@@ -343,77 +342,8 @@ 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>>) {
move_user_mcp_values(host);
let mut user = host.global_settings.effective().agents.mcp_servers;
fill_mcp_values(&host.agent_secrets, &mut user);
let user = host.global_settings.effective().agents.mcp_servers;
let project = host.project(window).ok().filter(|p| !p.light);
let workspace = project
.as_ref()
@@ -428,15 +358,10 @@ 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 {
@@ -445,9 +370,6 @@ 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 => {
@@ -496,17 +418,15 @@ fn listed(
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// 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.
/// The MCP servers agent sessions of the project at `root` get: the user's, 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 mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
fill_mcp_values(secrets, &mut user);
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
ide_agent::mcp::servers_from_settings(&user, &workspace)
}
@@ -673,7 +593,6 @@ 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();
@@ -686,7 +605,6 @@ 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()
@@ -695,27 +613,13 @@ mod tests {
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
on(session_mcp_servers(&settings, &approvals, &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, &secrets, &root)).contains(&(
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
+1 -41
View File
@@ -15,7 +15,6 @@ 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())
@@ -331,61 +330,21 @@ 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(
@@ -1092,6 +1051,7 @@ 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.
+3 -9
View File
@@ -188,14 +188,8 @@ pub async fn remote_download_into(
r
}
/// `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 async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
@@ -237,6 +231,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")?, c.arg("password")?).await
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
});
}
+1 -2
View File
@@ -675,10 +675,9 @@ 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, &secrets, &root)
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
}));
(db, http_history, agents)
}
-133
View File
@@ -1,133 +0,0 @@
//! 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");
}
-76
View File
@@ -2609,82 +2609,6 @@ 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(
+1 -1
View File
@@ -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(), None)
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
-15
View File
@@ -809,21 +809,6 @@ 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.
+2 -43
View File
@@ -241,9 +241,6 @@ 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>>,
@@ -511,7 +508,6 @@ 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()),
})
@@ -3206,44 +3202,14 @@ 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> {
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);
if let Some(d) = self.virtual_docs.lock().get(uri) {
return Ok(d.clone());
}
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"));
@@ -3387,18 +3353,11 @@ 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
}
-37
View File
@@ -172,20 +172,6 @@ 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);
@@ -318,29 +304,6 @@ 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(
+2 -17
View File
@@ -80,11 +80,6 @@ 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 } => {
@@ -95,24 +90,14 @@ impl RemoteService {
Ok(RemoteFs::new(session.sftp().await?).with_shell(session))
}
FsConnection::Ftp { server, secure, ftp } => {
let password = password.or_else(|| self.ssh.password(&ide_ssh::ftp::secret_key(server)));
let password = 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<()> {
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 fs = self.fs(connection).await?;
let result = fs.test(path).await;
fs.close().await;
result
+1 -21
View File
@@ -377,11 +377,6 @@ 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,
@@ -498,17 +493,7 @@ 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);
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
gradle_build_env(root, build_dir, &command.to_string_lossy(), s, None)
} else {
gradle_env(root, s, Default::default())
};
@@ -2043,11 +2028,6 @@ 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]
+1 -1
View File
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.14.0",
"1.13.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
-5
View File
@@ -110,11 +110,6 @@ 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) {
-3
View File
@@ -204,8 +204,6 @@ 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` |
@@ -270,7 +268,6 @@ 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` |
-3
View File
@@ -177,8 +177,6 @@ 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` |
@@ -241,7 +239,6 @@ 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` |
-87
View File
@@ -174,8 +174,6 @@ 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")
@@ -229,43 +227,6 @@ 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,
}) => {
@@ -551,51 +512,3 @@ test("console runs replace their unpinned results; pinned results stay until Clo
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();
});
-39
View File
@@ -1,39 +0,0 @@
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();
});
-17
View File
@@ -122,23 +122,6 @@ 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.");
+1 -1
View File
@@ -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.14.0
Bundle-Version: 1.13.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.
-3
View File
@@ -17,9 +17,6 @@
<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"/>
@@ -1,68 +0,0 @@
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);
}
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d6991b9065d8b623efdf9c03fb92b4875e95e666b3dadb3925ff5ad327f5fdcb
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+30 -111
View File
@@ -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 file's branch with
//! instance was reclaimed while idle), and the focused project's branch with
//! its ahead/behind counts in the status bar.
use openidea_plugin::host::{self, storage};
@@ -14,12 +14,7 @@ const LAST_FETCH: &str = "last-fetch:";
const SHOWN: &str = "shown-root";
#[derive(Default)]
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>,
}
pub struct GitToolbox;
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
@@ -72,51 +67,31 @@ fn tooltip(s: &RepositoryStatus) -> String {
lines.join("\n")
}
/// 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()? {
/// Shows `project`'s repository in the status bar (or hides the item).
fn show(project: &Project) -> Result<(), Error> {
match project.vcs_status()? {
Some(s) => {
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),
}
host::remove_status_item(ITEM);
Ok(None)
Ok(())
}
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
/// 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);
}
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.
@@ -156,22 +131,15 @@ 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 projects = self.candidates(ctx);
if projects.is_empty() {
return Err("no project is open".into());
}
let project = current(ctx).ok_or("no project is open")?;
match id {
"fetch" => {
// 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)?;
let n = fetch(std::slice::from_ref(&project))?;
show(&project)?;
Ok((n == 0).then(|| "The project is not in a git repository.".to_owned()))
}
"refresh" => {
show(&projects)?;
show(&project)?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
@@ -187,12 +155,9 @@ impl Plugin for GitToolbox {
host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string());
}
}
for e in events {
if let Event::EditorFocused(Some(path)) = e {
self.focused = Some(path.clone());
}
if let Some(p) = current(ctx) {
let _ = show(&p);
}
let _ = show(&self.candidates(ctx));
}
}
@@ -230,7 +195,7 @@ mod tests {
let host = MockHost::install();
host.add_project("/other");
host.set_vcs_status(Some(status(0, 4)));
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
GitToolbox.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");
@@ -243,72 +208,26 @@ 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)));
plugin.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
GitToolbox.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);
plugin.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
GitToolbox.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::default().handle_events(&[Event::Scheduled], &host.invocation());
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications()[0].body, "no network");
let err = GitToolbox::default().run_command("fetch", &host.invocation(), None).unwrap_err();
let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err();
assert_eq!(err, Error::Failed("no network".into()));
host.fail_fetch(Some(Error::Cancelled));
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report");
}
@@ -316,7 +235,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::default().handle_events(&[Event::Scheduled], &host.invocation());
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert!(host.notifications()[0].body.contains("vcs.write"));
assert_eq!(host.status_items()["branch"].text, "main ↓1");
}
-3
View File
@@ -199,8 +199,6 @@ 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` |
@@ -264,7 +262,6 @@ 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` |
+5 -29
View File
@@ -189,44 +189,22 @@ pub fn settings_get_global(state: State<'_, AppState>) -> Settings {
#[specta::specta]
pub fn settings_set(
window: Window,
app: tauri::AppHandle,
state: State<'_, AppState>,
scope: SettingsScope,
key: String,
value: String,
) -> IdeResult<Settings> {
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)?;
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), 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: key.to_owned() };
let event = ide_core::IdeEvent::SettingsChanged { key };
if applied.project_scope {
let owner = AppState::owner_window(window);
let owner = AppState::owner_window(window.label());
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());
@@ -249,7 +227,6 @@ 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)
}
@@ -267,7 +244,6 @@ 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
+1 -2
View File
@@ -158,7 +158,6 @@ 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, password).await
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await
}
-1
View File
@@ -676,7 +676,6 @@ 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,
-7
View File
@@ -27,13 +27,6 @@ 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]
+2 -16
View File
@@ -1594,8 +1594,6 @@ 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. */
@@ -1625,7 +1623,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, password: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path, password })),
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
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 })),
@@ -2504,7 +2502,7 @@ export type AskpassKind =
/** Yes / no (an unknown host key); answered with `yes` or `no`. */
"confirm";
export type AttachKind = "java" | "node" | "python" | "dotnet" | "go" | "native";
export type AttachKind = "java" | "node" | "python" | "go" | "native";
export type AttachableProcess = {
pid: number,
@@ -4092,14 +4090,6 @@ 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. */
@@ -4143,8 +4133,6 @@ 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,
@@ -9607,8 +9595,6 @@ 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,
-168
View File
@@ -1,168 +0,0 @@
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();
}
});
});
-271
View File
@@ -1,271 +0,0 @@
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>");
});
});
});
-84
View File
@@ -1,84 +0,0 @@
/**
* 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,
},
];
});
-14
View File
@@ -52,7 +52,6 @@ import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./c
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";
@@ -481,14 +480,6 @@ 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;
@@ -614,11 +605,6 @@ 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 = [];
+21 -61
View File
@@ -41,7 +41,7 @@ import {
} from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
import { ask, confirm } from "@/workbench/messages";
import { ask } from "@/workbench/messages";
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { isRemoteWindow } from "@/platform/remote";
@@ -92,7 +92,6 @@ import {
documentDirty,
documentLifecycle,
failures,
inputBusy,
inputDirty,
type CustomDocument,
} from "./model/customDocuments";
@@ -1227,40 +1226,27 @@ 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 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))));
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 },
);
}
activate(paneId: string, tabId: string, focus = true) {
@@ -1330,30 +1316,6 @@ 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,
@@ -1427,8 +1389,6 @@ 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;
+2 -6
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { registerActions } from "@/platform/actions";
import { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
import { maybeEditorService, notifications } from "@/workbench/contributions";
interface SourceState {
@@ -82,9 +82,6 @@ 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",
@@ -121,7 +118,6 @@ export function registerLibrarySourcesActions(): () => void {
return () => {
offActions();
offProject();
offRoots();
clear();
};
}
-13
View File
@@ -52,19 +52,6 @@ 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) =>
-1
View File
@@ -367,7 +367,6 @@ 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}
+1 -8
View File
@@ -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, getAction } from "@/platform/actions";
import { executeAction } 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,12 +106,6 @@ 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();
@@ -264,7 +258,6 @@ 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;
+3 -16
View File
@@ -235,16 +235,12 @@ 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",
@@ -258,10 +254,8 @@ const lintTheme = EditorView.theme({
".cm-lintRange-hint": { textDecoration: "none" },
// Typo level: IDEA's green wavy underline.
".cm-lintRange.cm-lsp-typo": {
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)",
textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
WebkitTextDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
textDecorationSkipInk: "none",
textUnderlineOffset: "3px",
},
@@ -269,16 +263,9 @@ 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: "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)",
},
".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" },
// 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))",
+2 -24
View File
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
data: [
{
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
},
],
});
@@ -67,27 +67,5 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
const after = view.state.facet(codeLenses);
expect(after).toHaveLength(1);
expect(after[0]!.pos).toBe(posBefore + 1);
expect(after[0]!.title).toBe("Run test");
});
it("leaves reference counts to Code Vision's usages", async () => {
vi.useFakeTimers();
const lens = (line: number, title: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
command: { title, command: "csharp.showReferences", arguments: [] },
});
ipc.lspCodeLens.mockResolvedValue({
status: "ok",
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
});
const view = new EditorView({
state: EditorState.create({
doc: "a\nb\nc\nd",
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
}),
parent: document.body,
});
views.push(view);
await vi.advanceTimersByTimeAsync(400);
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
expect(after[0]!.title).toBe("1 reference");
});
+1 -7
View File
@@ -6,12 +6,6 @@ import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
import { onLspRefresh, onServerSettled } from "./availability";
/**
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
* Usages shows the same count above the declaration, under its own setting.
*/
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
const set = StateEffect.define<CodeLens[]>();
const field = StateField.define<CodeLens[]>({
create: () => [],
@@ -68,7 +62,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
view.dispatch({
effects: set.of(
result.flatMap((l) =>
l.command && !REFERENCE_COUNT.test(l.command.title)
l.command
? [
{
pos: posToOffset(text, l.range.start),
+5 -24
View File
@@ -39,7 +39,6 @@ import { gutterIconMarkup } from "@/ui";
import {
MARKER_ICONS,
candidateKey,
implementsMarker,
inheritorsMarker,
markerCandidates,
supersMarker,
@@ -168,13 +167,10 @@ export async function fillMarkers(
groups.has("inheritors") && !cache.unsupported.has("inheritors")
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
: [];
const askSupers = groups.has("supers") && !cache.unsupported.has("supers");
const up = askSupers
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
const implicit = askSupers
? candidates.filter((c) => c.implicit && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
const up =
groups.has("supers") && !cache.unsupported.has("supers")
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
let implementations: Location[][] = [];
const known = down.length ? await fromHierarchy(path, down, deps.supers) : new Map<MarkerCandidate, Location[]>();
@@ -210,26 +206,13 @@ export async function fillMarkers(
}
const supers: SuperMethod[][] = [];
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
// Go: an implementation request on a type or method answers the interfaces it satisfies.
let satisfied: Location[][] = [];
if (implicit.length) {
try {
satisfied = await deps.implementations(implicit.map((c) => ({ line: c.line, col: c.col })));
} catch (e) {
if (isCancelled(e)) throw e;
if (isUnavailable(e)) cache.unsupported.add("supers");
implicit.length = 0;
}
}
const files = await deps.readFiles([
...implementations.flat().map((l) => l.path),
...supers.flat().map((s) => s.location.path),
...satisfied.flat().map((l) => l.path),
]);
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
implicit.forEach((c, i) => (entry(c).supers = implementsMarker(c, withoutSelf(path, c, satisfied[i] ?? []), files)));
}
/**
@@ -498,9 +481,7 @@ class MarkersPlugin {
if (stale()) return;
entry.outline = {
items,
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : ""), {
implicitInterfaces: ctx.language === "go",
}),
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
};
}
const { candidates, items } = entry.outline;
-63
View File
@@ -125,34 +125,7 @@ const BASE_OUTLINE = [
item("Base", "class", 0, 3, [item("run()", "method", 1, 1, [], 25), item("label()", "method", 2, 2, [], 18)], 22),
];
// main.go, as gopls outlines it: methods sit outside their type.
const GO = [
"package main",
"",
"type Shape interface {",
"\tArea() float64",
"}",
"",
"type Square struct{ side float64 }",
"",
"func (s *Square) Area() float64 { return s.side * s.side }",
"",
"type Celsius float64",
"",
"func main() {}",
];
const GO_PATH = "/p/main.go";
const GO_OUTLINE = [
item("Shape", "interface", 2, 4, [item("Area", "method", 3, 3, [], 1)], 5),
item("Square", "struct", 6, 6, [], 5),
item("(*Square).Area", "method", 8, 8, [], 17),
item("Celsius", "class", 10, 10, [], 5),
item("main", "function", 12, 12, [], 5),
];
const FILES: Record<string, string[]> = {
[GO_PATH]: GO,
"/go/src/fmt/print.go": ["package fmt", "type Stringer interface {", "\tString() string", "}"],
[SHAPE_PATH]: SHAPE,
"/p/Circle.java": CIRCLE,
"/p/Square.java": ["public class Square implements Shape {", " public double area() { return 2; }", "}"],
@@ -185,17 +158,6 @@ describe("marker candidates", () => {
]);
});
it("Go: types and receiver methods satisfy interfaces; only interfaces have implementations", () => {
const found = markerCandidates(GO_OUTLINE, lines(GO), { implicitInterfaces: true });
expect(found.map((c) => [c.name, c.method, c.inheritable, !!c.implicit])).toEqual([
["Shape", false, true, false],
["Area", true, true, false],
["Square", false, false, true],
["Area", true, false, true],
["Celsius", false, false, true],
]);
});
it("skips structs, enums and free functions for implementation requests", () => {
const outline = [item("Point", "struct", 0, 3, [item("len", "method", 1, 1)]), item("main", "function", 5, 6)];
const found = markerCandidates(outline, () => "");
@@ -266,31 +228,6 @@ describe("markers from server answers", () => {
expect(markers[0]!.chooserTitle).toBe("Choose Implementation of Shape (2 found)");
});
it("Go: marks the interfaces a type satisfies and the interface methods a method implements", async () => {
const { deps } = server(
{
[`${GO_PATH}:2`]: [loc(GO_PATH, 6, 5)],
[`${GO_PATH}:3`]: [loc(GO_PATH, 8, 17)],
[`${GO_PATH}:6`]: [loc(GO_PATH, 2, 5)],
[`${GO_PATH}:8`]: [loc(GO_PATH, 3, 1)],
[`${GO_PATH}:10`]: [loc("/go/src/fmt/print.go", 1, 5)],
},
{},
);
const candidates = markerCandidates(GO_OUTLINE, lines(GO), { implicitInterfaces: true });
const cache = emptyCache();
await fillMarkers(GO_PATH, candidates, ALL, deps(GO_PATH), cache);
const markers = cachedMarkers(candidates, ALL, cache);
expect(markers.map((m) => [m.line, m.kind, m.tooltip])).toEqual([
[2, "implemented", "Is implemented by Square"],
[3, "implemented", "Is implemented in Square"],
[6, "implementing", "Implements Shape"],
[8, "implementing", "Implements method in Shape"],
[10, "implementing", "Implements Stringer"],
]);
expect(markers[2]!.chooserTitle).toBe("Choose Implemented Interface of Square (1 found)");
});
it("marks subclassed classes and overridden methods", async () => {
const { deps } = server(
{
+2 -53
View File
@@ -38,11 +38,6 @@ export interface MarkerCandidate {
overriding: boolean;
/** Identifier position of the enclosing type (methods). */
owner: { line: number; col: number } | null;
/**
* Satisfies interfaces implicitly (Go): an implementation request on it answers the interfaces
* a type satisfies, or the interface methods a method implements.
*/
implicit?: boolean;
}
export interface LineMarker {
@@ -59,8 +54,6 @@ export interface LineMarker {
/** Types whose subtypes / implementations are worth a marker (not structs, enums: they cannot be extended). */
const INHERITABLE_TYPES = new Set<SymbolKind>(["class", "interface", "trait"]);
const ABSTRACT_TYPES = new Set<SymbolKind>(["interface", "trait"]);
/** Go declarations that can satisfy an interface: named types other than interfaces, and methods. */
const IMPLICIT_KINDS = new Set<SymbolKind>(["struct", "class", "method"]);
/** Types whose methods may implement or override a supertype's. */
const MEMBER_OWNERS = new Set<SymbolKind>(["class", "interface", "trait", "enum", "struct"]);
@@ -95,30 +88,12 @@ function declarationPrefix(item: OutlineItem, lineText: (line: number) => string
export function markerCandidates(
outline: readonly OutlineItem[],
lineText: (line: number) => string,
options: { implicitInterfaces?: boolean } = {},
): MarkerCandidate[] {
const out: MarkerCandidate[] = [];
const walk = (items: readonly OutlineItem[], parent: OutlineItem | null) => {
for (const it of items) {
const prefix = () => declarationPrefix(it, lineText);
const at = it.selection_range.start;
// Go: named types and their methods (`(*Square).Area`, outside the type) only satisfy
// interfaces; nothing extends them.
if (options.implicitInterfaces && !parent && IMPLICIT_KINDS.has(it.kind)) {
out.push({
symbol: it.name,
name: it.name.replace(/^\([^)]*\)\./, ""),
line: at.line,
col: at.col,
method: it.kind === "method",
abstract: false,
inheritable: false,
overriding: false,
owner: null,
implicit: true,
});
continue;
}
if (INHERITABLE_TYPES.has(it.kind)) {
const p = prefix();
out.push({
@@ -161,10 +136,7 @@ export function withoutSelf(path: string, c: MarkerCandidate, targets: readonly
return targets.filter((t) => !(t.path === path && t.range.start.line === c.line));
}
const TYPE_DECLARATION =
/\b(?:class|interface|trait|object|struct|enum|record|impl)\s+([\p{L}_$][\p{L}\p{N}_$]*)|^\s*type\s+([\p{L}_][\p{L}\p{N}_]*)(?:\[[^\]]*\])?\s+(?:interface|struct)\b/u;
/** A Go method's receiver type: `func (s *Square) Area()`. */
const GO_RECEIVER = /^\s*func\s*\(\s*(?:[\p{L}_][\p{L}\p{N}_]*\s+)?\*?\s*([\p{L}_][\p{L}\p{N}_]*)/u;
const TYPE_DECLARATION = /\b(?:class|interface|trait|object|struct|enum|record|impl)\s+([\p{L}_$][\p{L}\p{N}_$]*)/u;
const FENCES = [`"""`, `'''`];
@@ -194,15 +166,13 @@ export function enclosingType(lines: readonly string[] | null | undefined, targe
// A single-line docstring (fences open and close on the line) is text too.
if (FENCES.some((f) => code.includes(f))) continue;
const m = TYPE_DECLARATION.exec(code);
if (m && !/^\s*(\*|\/\*)/.test(line)) container = (m[1] ?? m[2])!;
if (m && !/^\s*(\*|\/\*)/.test(line)) container = m[1]!;
}
return container;
}
/** Name of the type a target sits in: the nearest type declaration above it, else the file name. */
export function enclosingTypeName(lines: readonly string[] | null | undefined, target: Location): string {
const receiver = GO_RECEIVER.exec(lines?.[target.range.start.line] ?? "")?.[1];
if (receiver) return receiver;
const found = enclosingType(lines, target);
if (found) return found;
const file = target.path.split(/[\\/]/).pop() ?? target.path;
@@ -252,27 +222,6 @@ export function inheritorsMarker(
return { kind, line: c.line, name: c.name, targets: [...targets], tooltip, chooserTitle };
}
/** ↑ marker of a Go type or method from the interfaces (methods) it satisfies; null without any. */
export function implementsMarker(
c: MarkerCandidate,
targets: readonly Location[],
files: ReadonlyMap<string, string[] | null>,
): LineMarker | null {
if (!targets.length) return null;
const names = nameList(targets.map((t) => targetName(files.get(t.path), t, c.method)));
const n = targets.length;
return {
kind: "implementing",
line: c.line,
name: c.name,
targets: [...targets],
tooltip: c.method ? tx`Implements method in ${names}` : tx`Implements ${names}`,
chooserTitle: c.method
? tx`Choose Super Method of ${c.name} (${n} found)`
: tx`Choose Implemented Interface of ${c.name} (${n} found)`,
};
}
/** Whether a super method is abstract: declared in an interface / trait, or marked `abstract`. */
export function isAbstractSuper(s: SuperMethod, files: ReadonlyMap<string, string[] | null>): boolean {
const lines = files.get(s.location.path);
-4
View File
@@ -180,10 +180,6 @@
display: flex;
flex-direction: column;
}
.taskSearch {
flex: none;
margin: 6px 8px 4px;
}
.history {
display: flex;
flex-direction: column;
-26
View File
@@ -391,32 +391,6 @@ describe("Agents pinned and grouped tasks", () => {
expect(treeRows().some((text) => text.includes("Pinned"))).toBe(false);
});
it("filters every section by the search, archived ones included, and highlights the match", () => {
const tasks = useAgents.getState().tasks;
const rename = (id: string, title: string, status: AgentTask["status"] = "ready") =>
tasks.set(id, { ...tasks.get(id)!, title, status });
rename("one", "Fix the lexer");
rename("two", "Refactor parser");
rename("three", "Lexer cleanup", "archived");
useAgents.setState({ tasks: new Map(tasks), organize: { groups: [frontend], groupOf: { two: "g1" }, pinned: [] } });
setup();
const field = screen.getByLabelText("Search tasks");
fireEvent.change(field, { target: { value: "lxr" } });
const rows = treeRows();
expect(rows.some((text) => text.includes("Fix the lexer"))).toBe(true);
// Archived is collapsed by default; a search looks inside it.
expect(rows.some((text) => text.includes("Lexer cleanup"))).toBe(true);
// The user group has no match: it goes.
expect(rows.some((text) => text.includes("Frontend") || text.includes("Refactor parser"))).toBe(false);
const lexer = screen.getAllByRole("treeitem").find((item) => item.textContent?.includes("Fix the lexer"))!;
expect([...lexer.querySelectorAll("mark")].map((m) => m.textContent).join("")).toBe("lxr");
fireEvent.change(field, { target: { value: "nothing like it" } });
expect(screen.getByText("No matching tasks")).toBeInTheDocument();
fireEvent.keyDown(field, { key: "Escape" });
expect(treeRows().some((text) => text.includes("Frontend"))).toBe(true);
});
it("shows a user group above the status groups, without its tasks in them too", () => {
useAgents.setState({ organize: { groups: [frontend], groupOf: { two: "g1" }, pinned: [] } });
setup();
+15 -70
View File
@@ -5,11 +5,9 @@ import type { AgentStatus as Status, AgentTask } from "@/bindings";
import { EDITOR_TAB_MIME, maybeEditorService, type EditorInput } from "@/workbench/contributions";
import {
EmptyState,
HighlightedText,
Icon,
IconButton,
Popup,
SearchField,
Spinner,
T,
Tree,
@@ -17,7 +15,6 @@ import {
showContextMenu,
startPointerDrag,
type IconName,
type TreeHandle,
} from "@/ui";
import {
SESSION_KIND,
@@ -40,7 +37,6 @@ import {
useAgents,
} from "./store";
import { groupMenu, taskMenu } from "./taskActions";
import { matchTask } from "./taskSearch";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
import styles from "./Agents.module.css";
@@ -191,8 +187,6 @@ export function AgentsView() {
return new Set(task ? [task] : []);
});
const organize = useAgents((s) => s.organize);
const [query, setQuery] = useState("");
const treeRef = useRef<TreeHandle>(null);
// Follow the active session tab.
useEffect(
() =>
@@ -207,32 +201,20 @@ export function AgentsView() {
}),
[],
);
// While searching: the matching tasks of every section, collapsed ones included; empty sections go.
const matches = useMemo(() => {
if (!query.trim()) return null;
const found = new Map<string, number[]>();
for (const task of tasks.values()) {
const hit = matchTask(task, query);
if (hit) found.set(task.id, hit);
}
return found;
}, [tasks, query]);
const roots = useMemo<Node[]>(() => {
const all = [...tasks.values()].filter((t) => !matches || matches.has(t.id));
const all = [...tasks.values()];
const pinned = pinnedMembers(all, organize);
return [
...(pinned.length ? [{ kind: "pinned", tasks: pinned } as Node] : []),
...organize.groups.flatMap((group): Node[] => {
const members = groupMembers(all, organize, group.id);
return matches && !members.length ? [] : [{ kind: "custom", group, tasks: members }];
}),
...organize.groups.map((group): Node => ({
kind: "custom",
group,
tasks: groupMembers(tasks.values(), organize, group.id),
})),
...groupTasks(ungroupedMembers(all, organize)).map((group): Node => ({ kind: "group", group })),
];
}, [tasks, organize, matches]);
const expanded = useMemo(
() => new Set(roots.map(nodeKey).filter((k) => matches || !collapsed.has(k))),
[roots, collapsed, matches],
);
}, [tasks, organize]);
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
// Dropping a session editor tab anywhere on the tool window docks it.
const viewRef = useRef<HTMLDivElement>(null);
const [tabOver, setTabOver] = useState(false);
@@ -269,35 +251,8 @@ export function AgentsView() {
const taskList = (
<div className={styles.tasks}>
{tasks.size > 0 && (
<SearchField
className={styles.taskSearch}
value={query}
onChange={setQuery}
clearable
placeholder={tx("Search tasks")}
aria-label={tx("Search tasks")}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
treeRef.current?.moveSelection(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter") {
const lead = treeRef.current?.leadKey();
if (lead && tasks.has(lead)) {
e.preventDefault();
showTask(lead);
}
} else if (e.key === "Escape" && query) {
e.preventDefault();
e.stopPropagation();
setQuery("");
}
}}
/>
)}
<Tree<Node>
aria-label="Agent tasks"
handleRef={treeRef}
roots={roots}
getKey={nodeKey}
getLabel={(n) =>
@@ -376,11 +331,7 @@ export function AgentsView() {
>
<Icon name={providerIcon(n.task.provider)} size={16} label={providerName(n.task.provider)} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{matches?.get(n.task.id)?.length ? (
<HighlightedText text={n.task.title} indices={matches.get(n.task.id)!} />
) : (
n.task.title
)}
{n.task.title}
</span>
{/* Ready tasks say so by their group; the others show what they're doing. */}
{n.task.status !== "ready" && <TaskStatusIcon status={n.task.status} />}
@@ -401,18 +352,12 @@ export function AgentsView() {
}
}}
empty={
matches ? (
<div className={styles.empty}>
<T>No matching tasks</T>
</div>
) : (
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
)
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
-14
View File
@@ -1,14 +0,0 @@
/** Filtering the Agents task list: fuzzy on the title (highlighted), word starts on the project folder and branch. */
import type { AgentTask } from "@/bindings";
import { fuzzyMatch, wordMatch } from "@/ui";
import { basename } from "@/platform/project";
/** Indices of `query` in the task's title to highlight ([] when another field matched), or null when it doesn't match. */
export function matchTask(task: AgentTask, query: string): number[] | null {
const q = query.trim();
if (!q) return [];
const title = fuzzyMatch(task.title, q);
if (title) return title;
const others = [task.projectRoot ? basename(task.projectRoot) : null, task.branch];
return others.some((text) => text && wordMatch(text, q)) ? [] : null;
}
-1
View File
@@ -77,7 +77,6 @@ export function activate(): () => void {
icon: "toolwindow-usages",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 35,
component: UsagesView,
headerContent: UsagesTabs,
@@ -107,29 +107,3 @@ describe("routine grouping", () => {
expect(tree.queryByText("functions")).toBeNull();
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
treeExpanded: new Set(),
});
render(<DatabaseView />);
await act(() => executeAction("db.expandAll"));
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
"pg",
"pg/public",
"pg/public/routines",
"pg/public/tables",
]);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
expect(tree.getByText("refresh_data")).toBeInTheDocument();
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
+11 -37
View File
@@ -58,7 +58,7 @@ import {
useDatabase,
type Loaded,
} from "./store";
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -366,34 +366,6 @@ export function DatabaseView() {
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
const expandable = (n: Node) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group";
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
useEffect(() => {
databaseTree.expandAll = () => {
const next = new Set(useDatabase.getState().treeExpanded);
const walk = (nodes: readonly Node[]) => {
for (const n of nodes) {
const kids = expandable(n) ? children(n) : undefined;
if (!kids) continue;
next.add(n.key);
walk(kids);
}
};
walk(roots);
useDatabase.setState({ treeExpanded: next });
};
return () => {
databaseTree.expandAll = null;
};
});
const label = (n: Node): string => {
switch (n.kind) {
case "source":
@@ -537,7 +509,14 @@ export function DatabaseView() {
getKey={(n) => n.key}
getLabel={label}
getChildren={children}
isExpandable={expandable}
isExpandable={(n) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group"
}
loadChildren={load}
getIcon={(n) => icon(n)}
getDetail={detail}
@@ -683,12 +662,7 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
/** Header controls (shown on hover, like IDEA's): Collapse All. */
export function DatabaseHeaderActions() {
return (
<>
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
);
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
}
+3 -59
View File
@@ -12,7 +12,6 @@ import {
useRef,
useState,
type ClipboardEvent,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
} from "react";
@@ -393,7 +392,6 @@ export function Grid(props: GridProps) {
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
canSetNull: () => canEdit() && nullOk(norm(sel!)),
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
canFilter: () => !!props.onFilter && !!sel && !editing,
toggle: () => {
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
},
@@ -403,12 +401,6 @@ export function Grid(props: GridProps) {
setDefault: () => {
if (actions.canSetDefault()) setDefaults(norm(sel!));
},
filter: () => {
if (!actions.canFilter()) return;
scrollColumnIntoView(sel!.c1);
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
if (anchor) props.onFilter!(sel!.c1, anchor);
},
};
Object.assign((cellActions.current ??= {} as CellActions), actions);
});
@@ -438,14 +430,6 @@ export function Grid(props: GridProps) {
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
return;
}
if (e.key === "Tab") {
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
if (!next) return;
e.preventDefault();
moveTo(next[0], next[1], false);
return;
}
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
@@ -455,32 +439,13 @@ export function Grid(props: GridProps) {
const m = moves[e.key];
if (!m) return;
e.preventDefault();
moveTo(
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
e.shiftKey,
);
};
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
};
const moveTo = (r: number, c: number, extend: boolean) => {
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
const pointerFocus = useRef(false);
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
if (rowCount && columns.length) moveTo(0, 0, false);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
if (e.button !== 0) {
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
@@ -547,9 +512,6 @@ export function Grid(props: GridProps) {
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
);
}
if (props.onFilter) {
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
}
const extra = rowMenu?.(selectedRows()) ?? [];
if (extra.length) items.push({ kind: "separator" }, ...extra);
showContextMenu(e, items);
@@ -584,11 +546,6 @@ export function Grid(props: GridProps) {
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onFocus={onFocus}
onMouseDownCapture={() => {
pointerFocus.current = true;
setTimeout(() => (pointerFocus.current = false));
}}
onPaste={onPaste}
onContextMenu={onContextMenu}
>
@@ -658,10 +615,6 @@ export function Grid(props: GridProps) {
{props.onFilter && (
<button
type="button"
data-column-filter=""
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
// The keyboard opens it with the Local Filter action.
tabIndex={-1}
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
aria-label={`Filter ${c.name}`}
title={tx("Local filter")}
@@ -756,13 +709,6 @@ export function Grid(props: GridProps) {
return;
}
if (e.key === "Enter") commit();
if (e.key === "Tab") {
// Like IDEA's table editor: Tab commits and selects the next cell.
e.preventDefault();
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
commit();
if (next) moveTo(next[0], next[1], false);
}
if (e.key === "Escape") {
skipBlurCommit.current = true;
setEditing(null);
@@ -780,7 +726,6 @@ export function Grid(props: GridProps) {
<div
key={c}
role="gridcell"
aria-selected={selected(r, c)}
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
@@ -806,7 +751,6 @@ export function Grid(props: GridProps) {
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox
tabIndex={-1}
className={styles.cellCheckbox}
aria-label={tx`Toggle ${columns[c]!.name}`}
checked={val === lit.t}
+14 -25
View File
@@ -21,7 +21,6 @@ import {
showContextMenu,
T,
tooltipAttrs,
Toolbar,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
@@ -81,10 +80,10 @@ function Output() {
}, [lines.length]);
return (
<div className={styles.view}>
<Toolbar className={styles.toolbar} aria-label="Output actions">
<div className={styles.toolbar}>
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</Toolbar>
</div>
<div
ref={ref}
className={styles.output}
@@ -149,7 +148,12 @@ function Pager({ tab }: { tab: ResultTab }) {
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
return (
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
<div
className={styles.resultPager}
role="toolbar"
aria-label={tx("Result pages")}
title={outcomeSummary(tab.outcome)}
>
<IconButton
icon="page-first"
title="First Page"
@@ -194,7 +198,7 @@ function Pager({ tab }: { tab: ResultTab }) {
onChange={(v) => go(0, Number(v))}
disabled={!canPage}
/>
</Toolbar>
</div>
);
}
@@ -429,7 +433,7 @@ function Result({ tab }: { tab: ResultTab }) {
}
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<Toolbar className={styles.toolbar} aria-label="Result actions">
<div className={styles.toolbar}>
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
@@ -483,7 +487,7 @@ function Result({ tab }: { tab: ResultTab }) {
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
/>
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
</Toolbar>
</div>
{view === "chart" ? (
<div className={styles.fill}>
<ChartView columns={columns} rows={r.rows} />
@@ -591,7 +595,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
const selectedKey = selected < 0 ? statement.key : String(selected);
return (
<div className={styles.view}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
@@ -599,7 +603,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</Toolbar>
</div>
<div className={clsx(styles.fill, styles.planView)}>
<div className={styles.planTree}>
<div className={styles.planHeader}>
@@ -673,13 +677,6 @@ export function ResultsView() {
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
const root = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(null);
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
const focusContent = () => {
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
target?.focus();
return !!target && document.activeElement === target;
};
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
@@ -707,7 +704,6 @@ export function ResultsView() {
<div
ref={root}
className={styles.view}
data-focus-scope=""
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -728,7 +724,6 @@ export function ResultsView() {
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
focusContent={focusContent}
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
@@ -749,13 +744,7 @@ export function ResultsView() {
]}
/>
</ToolWindowContentTabs>
<div
ref={panel}
className={styles.fill}
role="tabpanel"
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
>
<div className={styles.fill}>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
</div>
-11
View File
@@ -5,12 +5,9 @@ export interface CellActions {
canToggle(): boolean;
canSetNull(): boolean;
canSetDefault(): boolean;
canFilter(): boolean;
toggle(): void;
setNull(): void;
setDefault(): void;
/** Opens the local filter of the selected cell's column. */
filter(): void;
}
const grids = new WeakMap<Element, CellActions>();
@@ -62,14 +59,6 @@ export function installCellActions(): () => void {
enabled: (c) => handle(c)?.canSetDefault() ?? false,
run: (c) => handle(c)?.setDefault(),
},
{
id: "db.localFilter",
title: "Local Filter",
category: "Database",
keyWhen: "dbCellGridFocus",
enabled: (c) => handle(c)?.canFilter() ?? false,
run: (c) => handle(c)?.filter(),
},
]);
return () => {
dispose();
@@ -1,5 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
@@ -433,75 +432,6 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
});
});
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
const selectedCells = () =>
screen
.getAllByRole("gridcell")
.filter((cell) => cell.getAttribute("aria-selected") === "true")
.map((cell) => cell.textContent);
const rows = [
["1", "Ann"],
["2", "Bob"],
];
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
await screen.findByRole("button", { name: "Add Row" });
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
await user.tab();
expect(screen.getByRole("grid")).toHaveFocus();
expect(selectedCells()).toEqual(["1"]);
await user.tab();
expect(selectedCells()).toEqual(["Ann"]);
await user.tab();
expect(selectedCells()).toEqual(["2"]);
await user.tab({ shift: true });
await user.tab({ shift: true });
expect(selectedCells()).toEqual(["1"]);
await user.tab({ shift: true });
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
document.activeElement as HTMLElement,
);
await user.tab({ shift: true });
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
});
it("leaves the grid after the last cell", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
await user.tab();
expect(screen.getByRole("grid")).not.toHaveFocus();
expect(selectedCells()).toEqual(["Bob"]);
});
it("commits the cell editor with Tab and selects the next cell", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Cara" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
expect(selectedCells()).toEqual(["2"]);
expect(screen.getByRole("grid")).toHaveFocus();
});
it("goes from the Output tab to the log, past its toolbar", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
act(() => screen.getByRole("tab", { name: "Output" }).focus());
await user.tab();
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
});
});
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
-10
View File
@@ -44,10 +44,6 @@ import { tx } from "@/platform/i18n";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
const OPEN = "projectOpen";
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
@@ -485,12 +481,6 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.expandAll",
title: "Expand All",
icon: "expand-all",
run: () => databaseTree.expandAll?.(),
}),
tree({
id: "db.collapseAll",
title: "Collapse All",
+1 -13
View File
@@ -19,14 +19,7 @@ import { attachConfig, attachDetail, attachRows, matchesFilter } from "../attach
const proc = (p: Partial<AttachableProcess> & Pick<AttachableProcess, "pid" | "kind">): AttachableProcess => ({
name: p.kind,
commandLine: `${p.kind} app`,
adapter: {
java: "java-debug",
node: "js-debug",
python: "debugpy",
dotnet: "netcoredbg",
go: "delve",
native: "codelldb",
}[p.kind],
adapter: { java: "java-debug", node: "js-debug", python: "debugpy", go: "delve", native: "codelldb" }[p.kind],
host: null,
port: null,
...p,
@@ -45,7 +38,6 @@ const list = [
}),
proc({ pid: 25, kind: "node", name: "node", commandLine: "node server.js" }),
proc({ pid: 26, kind: "go", name: "gosrv", commandLine: "./gosrv" }),
proc({ pid: 40, kind: "dotnet", name: "Api", commandLine: "./Api --urls http://localhost:5000" }),
];
describe("Attach to Process popup model", () => {
@@ -55,7 +47,6 @@ describe("Attach to Process popup model", () => {
"Java (JDWP):1",
"Node.js:1",
"Python (debugpy):2",
".NET (netcoredbg):1",
"Go (Delve):1",
"Native (LLDB):1",
]);
@@ -92,9 +83,6 @@ describe("Attach to Process popup model", () => {
address: "localhost",
});
expect(await extra(list[5]!)).toEqual({ mode: "local", processId: 26 });
const dotnet = await attachConfig(list[6]!, "/proj");
expect(dotnet).toMatchObject({ adapter: "netcoredbg", request: "attach" });
expect(JSON.parse(dotnet.extraJson!)).toMatchObject({ processId: 40 });
const native = await attachConfig(list[0]!, "/proj");
expect(native).toMatchObject({ adapter: "codelldb", request: "attach", name: "a.out (30)", cwd: "/proj" });
expect(JSON.parse(native.extraJson!)).toEqual({ pid: 30 });
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
expect(await screen.findByText("Collecting data…")).toBeTruthy();
adapterData.library = "com.example.library.Library@1";
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
const value = await screen.findByText("{Library@1}");
const value = await screen.findByText("com.example.library.Library@1");
expect(screen.queryByText("Collecting data…")).toBeNull();
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
});
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
await screen.findByText("Collecting data…");
adapterData.library = "Library@2";
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
expect(await screen.findByText("{Library@2}")).toBeTruthy();
expect(await screen.findByText("Library@2")).toBeTruthy();
});
it("does nothing for an invalidation while the program runs", async () => {
+1 -23
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { selectionExpression } from "../editor/valueTooltip";
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
import { inlineValuesFor } from "../editor/execution";
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
import type { StackFrame } from "@/platform/ipc";
@@ -150,25 +150,3 @@ describe("selectionExpression", () => {
expect(selectionExpression(multi)).toBeNull();
});
});
describe("shown values", () => {
it.each([
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
["Db@21", "Db", { label: "{Db@21}", text: "" }],
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
['"hello"', "String", { label: null, text: '"hello"' }],
["42", "int", { label: "{int}", text: "42" }],
// Other debuggers' values keep their text and type.
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
["0x1000", null, { label: null, text: "0x1000" }],
])("%s {%s}", (value, type, shown) => {
expect(shownValue(value, type)).toEqual(shown);
});
});
+1 -21
View File
@@ -28,13 +28,7 @@ import {
import { removeAllSpecial, specialAt, toggleFieldAt, toggleMethodAt } from "./breakpoints/special";
import { fieldAt, methodAt } from "./breakpoints/symbols";
import { toggleBreakpointSmart } from "./breakpoints/toggle";
import {
isJavaAttachTarget,
isJavaDebuggable,
javaAttachConfig,
javaLaunchConfig,
jvmAttachDebuggerReady,
} from "./java";
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap";
import { refreshMemory, useMemory } from "./memory/memoryState";
import { closeInstances, instancesOutdated, showInstances, showReferrers, useInstances } from "./memory/instancesState";
@@ -484,20 +478,6 @@ export function registerDebugActions(): () => void {
})(),
),
},
{
id: "debug.jvmAttachReady",
title: "Check the JVM Debugger",
category: "Debug",
hidden: true,
description:
"Whether the debugger for a build tool's JVMs is installed, offering the install when not (arg: { module, retry, done(ready) })",
run: ({ arg }) => {
const a = arg as { module: string; retry: () => void; done: (ready: boolean) => void };
const root = useProject.getState().project?.root;
if (!root) return a.done(true);
void jvmAttachDebuggerReady(a.module, root, a.retry).then(a.done, () => a.done(true));
},
},
{
id: "debug.stop",
title: "Stop",
+1 -4
View File
@@ -10,12 +10,11 @@ export const KIND_LABELS: Record<AttachKind, string> = {
java: "Java (JDWP)",
node: "Node.js",
python: "Python (debugpy)",
dotnet: ".NET (netcoredbg)",
go: "Go (Delve)",
native: "Native (LLDB)",
};
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "dotnet", "go", "native"];
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "go", "native"];
export type AttachRow =
| { kind: "group"; key: string; label: string; count: number }
@@ -85,8 +84,6 @@ export async function attachConfig(p: AttachableProcess, root: string): Promise<
// (`--pid` injection ships only an x86_64 helper on macOS.)
p.port != null ? { debugServer: p.port, justMyCode: skip } : { processId: p.pid, justMyCode: skip },
);
case "dotnet":
return base(p, root, { processId: p.pid, justMyCode: skip });
case "go":
return base(p, root, { mode: "local", processId: p.pid });
case "native":
+3 -5
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { activeSession, useExecutionPoint } from "../state";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { tx } from "@/platform/i18n";
import { identifiersIn, inlineValue, shownValue } from "../values";
import { identifiersIn, inlineValue } from "../values";
import { valueHint } from "./valueTooltip";
import styles from "./editor.module.css";
@@ -113,13 +113,11 @@ export function inlineValuesFor(
evaluateName: id,
})
: raw;
const shown = shownValue(variable.value, variable.typeName);
const value = shown.text || shown.label || variable.value;
parts.push({
name: id,
text: `${id}: ${inlineValue(value, 40)}`,
text: `${id}: ${inlineValue(variable.value, 40)}`,
variable,
truncated: value.length > 40,
truncated: variable.value.length > 40,
});
if (parts.length >= MAX_PER_LINE) break;
}
+8 -14
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { workflowSettings } from "@/platform/workflowSettings";
import { navigationModifierHeld } from "@/platform/navigationModifier";
import { activeSession, evaluate } from "../state";
import { inlineValue, shownValue } from "../values";
import { inlineValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup, selectableText } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
@@ -70,19 +70,13 @@ export function valueHint(
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
const shown = shownValue(value.value, value.typeName);
// A JVM object's `{Type@id}` leads its value; other types follow it.
const leading = shown.label?.includes("@") ?? false;
const type = (text: string) => {
const span = document.createElement("span");
span.className = styles.valueType!;
span.textContent = text;
return span;
};
dom.append(name);
if (leading) dom.append(type(`${shown.label} `));
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
return { dom, destroy: () => tree?.unmount() };
}
-7
View File
@@ -48,11 +48,6 @@ import { forgetMemory } from "./memory/memoryState";
const useAnyOverlay = () =>
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
/** The Debug stripe button's badge, like Run's: how many debug sessions (child sessions aside) are live. */
function useLiveSessionCount(): number | null {
return useDebug((s) => Object.values(s.sessions).filter((x) => x.parent === null && isLive(x)).length || null);
}
export function activate(): () => void {
useDebugRunSessions.setState({
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
@@ -73,11 +68,9 @@ export function activate(): () => void {
icon: "toolwindow-debug",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 20,
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
useBadge: useLiveSessionCount,
}),
registerEditorKind(heapEditorKind),
installHeapContext(),
-23
View File
@@ -80,29 +80,6 @@ export async function moduleHasKotlinSources(cwd: string): Promise<boolean> {
return false;
}
/**
* Whether the debugger for the JVMs of `module` is installed. Maven / Gradle start those JVMs
* before the debugger attaches, so a missing Kotlin debugger is offered here, before the build
* runs: "Install and Debug" installs it and calls `retry`.
*/
export async function jvmAttachDebuggerReady(module: string, root: string, retry: () => void): Promise<boolean> {
if (!(await moduleHasKotlinSources(module ? join(root, module) : root))) return true;
const tools = await call(commands.toolsList()).catch(() => []);
if (tools.find((t) => t.name === KOTLIN_ADAPTER)?.status !== "missing") return true;
notifications().notify({
group: "run",
level: "warning",
key: "debug.start",
title: "The Kotlin debugger is not installed",
body: "This module has Kotlin sources, so debugging it needs the Kotlin debugger (kotlin-debug-adapter).",
sticky: true,
actions: [
{ label: "Install and Debug", run: () => void executeAction("tools.install", KOTLIN_ADAPTER).then(retry) },
],
});
return false;
}
/** java-debug missing from jdtls: offer the one-click install (jdtls restarts with the bundle). */
function offerInstall(e: unknown): never {
if (e instanceof IpcError && e.kind === "unavailable" && /java-debug|debugger/i.test(e.message)) {
+25 -30
View File
@@ -176,7 +176,30 @@ export function DebugToolWindow() {
return (
<div className={styles.root}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
<span className={styles.sep} aria-hidden />
<ToolButton action="debug.rerun" icon="rerun" />
<ToolButton action="debug.stop" icon="stop" tone="danger" />
<span className={styles.sep} aria-hidden />
@@ -210,29 +233,6 @@ export function DebugToolWindow() {
onClick={() => setMuted(!muted)}
/>
<MoreActions />
<span className={styles.sep} aria-hidden />
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
</div>
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
{tab === "variables" ? (
@@ -251,12 +251,7 @@ export function DebugToolWindow() {
function ThreadsAndVariables({ session }: { session: DebugSession }) {
return (
<SplitGroup
orientation="horizontal"
id="debug-threads-variables"
storageKey="openidea.debug.threadsVariables"
className={styles.split}
>
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
<FramesPane session={session} />
</SplitPanel>
+7 -12
View File
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
import { methodAt } from "../breakpoints/symbols";
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
import { blurDebugView, focusDebugView } from "./focusedView";
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
import { classifyValue, oneLine, sortVariables } from "../values";
import { valueText } from "../valueText";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./VariablesView.module.css";
@@ -486,25 +486,20 @@ function VarRow({
</span>
);
const kind = node.error ? null : classifyValue(node.value, node.typeName);
const shown = shownValue(node.value, node.typeName);
return (
<span className={styles.row}>
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
{!shown.entry && (
<>
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
</>
)}
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
{node.error ? (
<span className={styles.error}>{oneLine(node.error)}</span>
) : (
<>
{shown.label && <span className={styles.type}>{shown.label}</span>}
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
{oneLine(shown.text)}
{oneLine(node.value)}
</span>
{overflow && (
<button
-32
View File
@@ -69,35 +69,3 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
}
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
export interface ShownValue {
label: string | null;
text: string;
/** A map entry: the row shows `key -> value` alone, without its index. */
entry?: true;
}
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
/**
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
* a type. Values in other forms keep the adapter's text under `{type}`.
*/
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
if (!typeName) return plain;
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
const object = JVM_OBJECT.exec(value);
if (!object) return plain;
const [, head, id, rest = ""] = object;
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
if (simple(head!) !== simple(typeName)) return plain;
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
}
+1 -31
View File
@@ -5,7 +5,6 @@ import type { SyncEntry } from "@/platform/ipc";
const mocks = vi.hoisted(() => ({
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
servers: [] as DeploymentServerValid[],
groups: [] as { id: string; name: string; servers: string[] }[],
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
choose: vi.fn(),
notify: vi.fn(),
@@ -38,7 +37,7 @@ vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/project" } }) },
}));
vi.mock("@/platform/workflowSettings", () => ({
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
}));
vi.mock("@/workbench/contributions", () => ({
notifications: () => ({ notify: mocks.notify }),
@@ -73,7 +72,6 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
beforeEach(() => {
vi.clearAllMocks();
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
mocks.groups = [];
mocks.coverage = [
{
server: "one",
@@ -93,34 +91,6 @@ beforeEach(() => {
useDeployment.setState({ sync: null });
registerDeploymentActions();
});
describe("server groups", () => {
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
mocks.groups = [
{ id: "g", name: "Production", servers: ["one", "two"] },
{ id: "none", name: "Elsewhere", servers: ["two"] },
];
mocks.coverage[1]!.paths = [];
mocks.choose.mockResolvedValue("Production (group)");
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
await run("deployment.uploadTo");
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
await vi.waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
),
);
expect(mocks.upload).toHaveBeenCalledTimes(1);
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
});
it("uploads to every covered server of the group in order", async () => {
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
mocks.coverage[1]!.paths = ["/project/b"];
mocks.choose.mockResolvedValue("Production (group)");
await run("deployment.uploadTo");
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
});
});
describe("deployment eligibility", () => {
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
mocks.servers = [mocks.servers[0]!];
+6 -35
View File
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
: "No deployment server covers the selected paths",
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
group: GROUP,
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
key: "deployment.noCoverage",
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
});
}
/** The servers covering `paths`, or null (after saying why) when none does. */
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
/** Keep the chooser visible even when only one server covers the selection. */
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
const configured = deployment().servers;
if (!configured.length) {
notifyNoServers();
@@ -119,36 +119,11 @@ async function coveringServers(paths: string[]): Promise<DeploymentServerValid[]
}
return null;
}
return servers;
}
/** Keep the chooser visible even when only one server covers the selection. */
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
const servers = await coveringServers(paths);
if (!servers) return null;
const labels = servers.map(serverLabel);
const picked = await chooseItem({ title, items: labels });
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
}
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
const servers = await coveringServers(paths);
if (!servers) return null;
const { servers: configured, groups } = deployment();
const choices = new Map<string, DeploymentServerValid[]>();
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
for (const group of groups) {
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
if (!members.some((m) => servers.includes(m))) continue;
const name = group.name.trim() || group.id;
const label = tx`${name} (group)`;
if (!choices.has(label)) choices.set(label, members);
}
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
return picked === null ? null : (choices.get(picked) ?? null);
}
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
return resolveServer(deployment(), null) ?? pickServer(title, paths);
}
@@ -185,8 +160,7 @@ export function showReport(
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
group: GROUP,
// One per server, so each server of a group reports on its own.
key: `deployment.${verb}.${server.id}`,
key: `deployment.${verb}`,
});
}
@@ -197,7 +171,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
title: title.replace("{server}", serverLabel(server)),
body: errorText(e),
group: GROUP,
key: `deployment.failure.${server.id}`,
key: "deployment.failure",
});
}
@@ -477,10 +451,7 @@ export function registerDeploymentActions(): () => void {
category: CATEGORY,
when,
keywords: ["deploy", "sftp", "upload", "remote"],
run: withTargets(async (paths) => {
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
}),
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
},
{
id: "deployment.downloadRemoteInto",
-6
View File
@@ -55,12 +55,6 @@ export const UI_KEYS: Record<string, string[]> = {
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
// The selected tab of a part without focus (src/ui/focusScope.ts).
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
"--border-tab-selected-inactive": [
"EditorTabs.inactiveUnderlinedTabBorderColor",
"EditorTabs.inactiveUnderlineColor",
],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
@@ -301,32 +301,6 @@ it("offers workspace removal for every member root", () => {
);
});
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
useProject.setState({
project: {
root: "/work/workspaces/demo",
name: "demo",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
},
});
setContext("vcsAvailable", true);
try {
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
// Project structure is the workspace folder's: its roots can't name a folder outside it.
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
// Scratches stay outside.
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
} finally {
clearContext("vcsAvailable");
}
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
+2 -5
View File
@@ -151,9 +151,6 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
// Workspace members may live outside the workspace folder; they have VCS and usages too.
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
@@ -231,7 +228,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.open"),
run: () => files.forEach((f) => void openFile(f.path)),
},
inWorkspace &&
inProject &&
singleFile && {
id: "findUsages",
label: "Find Usages",
@@ -341,7 +338,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
);
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
-2
View File
@@ -34,7 +34,6 @@ import {
initProjectTree,
leadSelection,
reloadAll,
reloadArchiveDirs,
reloadLoadedDirs,
setChangedFiles,
setOpenFiles,
@@ -254,7 +253,6 @@ export function activate(): () => void {
initProjectTypes(),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)),
onIdeEvent("projectRootsChanged", () => void reloadArchiveDirs()),
];
return () => {
disposers.reverse().forEach((d) => d());
-23
View File
@@ -3,7 +3,6 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
import { visibleInfo } from "./model";
import {
applyFsChange,
reloadArchiveDirs,
reloadLoadedDirs,
currentModel,
expandAll,
@@ -115,28 +114,6 @@ describe("refresh of excluded folders", () => {
});
});
describe("refresh of library archives", () => {
it("re-reads the loaded folders inside archives only", async () => {
await fs.createFile("/m2/lib.jar!/p/A.class");
await loadDir(ROOT);
await loadDir("/m2/lib.jar!");
await loadDir("/m2/lib.jar!/p");
await fs.createFile("/m2/lib.jar!/p/B.class");
const spy = vi.spyOn(fs, "readDir");
await reloadArchiveDirs();
expect(spy.mock.calls.map(([p]) => p).sort()).toEqual(["/m2/lib.jar!", "/m2/lib.jar!/p"]);
expect(
useProjectTree
.getState()
.listings.get("/m2/lib.jar!/p")
?.map((e) => e.name),
).toEqual(["A.class", "B.class"]);
spy.mockRestore();
});
});
describe("refresh on ignore-rule changes", () => {
it("refreshes loaded descendants once, preserving selection and unrelated folders", async () => {
await fs.createFile("/p/docs/sub/note.md");
-10
View File
@@ -565,16 +565,6 @@ export async function reloadLoadedDirs(dirs: readonly string[]) {
await Promise.all(dirs.filter((p) => listings.has(p)).map((p) => loadDir(p, true)));
}
/**
* Re-reads the loaded folders inside archives (`<archive>!…`): a build tool sync may have replaced
* a library jar in place (a SNAPSHOT installed again), which no file watcher reports.
*/
export async function reloadArchiveDirs() {
const { listings } = get();
const inArchive = (p: string) => p.endsWith("!") || p.includes("!/") || p.includes("!\\");
await Promise.all([...listings.keys()].filter(inArchive).map((p) => loadDir(p, true)));
}
/** Re-reads every loaded folder (View › Reload from Disk). */
export async function reloadAll() {
const { listings } = get();
-1
View File
@@ -381,7 +381,6 @@ export function activate(): () => void {
icon: "toolwindow-run",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 5,
component: lazyComponent(() => import("./RunView").then((m) => m.RunView)),
headerContent: lazyComponent(() => import("./RunView").then((m) => m.RunTabs)),
+1 -18
View File
@@ -4,8 +4,6 @@ import type { RunConfig } from "@/platform/ipc";
const h = vi.hoisted(() => ({
attached: [] as number[],
onLine: null as ((line: string) => void) | null,
ready: true,
retry: null as (() => void) | null,
}));
vi.mock("@/platform/ipc", async (orig) => {
const ipc = await orig<typeof import("@/platform/ipc")>();
@@ -17,12 +15,8 @@ vi.mock("@/platform/ipc", async (orig) => {
});
vi.mock("@/platform/actions", async (orig) => ({
...(await orig<typeof import("@/platform/actions")>()),
executeAction: async (id: string, arg: { port: number; retry: () => void; done: (ready: boolean) => void }) => {
executeAction: async (id: string, arg: { port: number }) => {
if (id === "debug.attach") h.attached.push(arg.port);
if (id === "debug.jvmAttachReady") {
h.retry = arg.retry;
arg.done(h.ready);
}
return true;
},
}));
@@ -50,8 +44,6 @@ const mavenTest: RunConfig = {
beforeEach(() => {
h.attached = [];
h.onLine = null;
h.ready = true;
h.retry = null;
});
describe("Maven test debugging", () => {
@@ -74,13 +66,4 @@ describe("Maven test debugging", () => {
h.onLine!("Listening for transport dt_socket at address: 50123");
expect(h.attached).toEqual([50123]);
});
it("doesn't start the build while the debugger is missing; its install debugs again", async () => {
h.ready = false;
expect(await debugWithAgent(mavenTest)).toBe(true);
expect(h.onLine).toBeNull();
h.ready = true;
h.retry!();
await vi.waitFor(() => expect(h.onLine).not.toBeNull());
});
});
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more