Compare commits

..
Author SHA1 Message Date
l0ng-ai 953b51b8c6 fix(debugger): show JVM values as {Type@id} summary and map entries as key -> value
The Variables view showed the Java debugger's text under its type: args = {String[]} String[0]@9, map = {HashMap} HashMap@10 size=1, and a map entry as 0 = {HashMap$Node} HashMap$Node@31 "a":"b". A JVM object now reads {String[0]@9} [] and {HashMap@10} size = 1, a map entry as "a" -> "b" without its index, and a String without its type. Inline values and the value tooltip use the same text (args: [], map: size = 1). Values from other debuggers keep their text and type.
2026-11-13 19:23:54 +08:00
64 changed files with 101 additions and 1313 deletions

No files matched your search

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>
-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)
}
+3 -13
View File
@@ -583,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 {
@@ -599,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()
@@ -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 {
@@ -431,7 +428,6 @@ async fn replay(name: &str) -> Replayed {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
@@ -465,23 +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 replay_multi_turn() {
let r = replay("multi_turn").await;
@@ -748,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;
@@ -792,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 =
-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
@@ -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 -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.
-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(
+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"),
)
}
-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);
}
}
}
+1 -1
View File
@@ -2502,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,
+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();
};
}
+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))",
+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,
+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 });
+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":
-1
View File
@@ -73,7 +73,6 @@ 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)),
-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)) {
-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());
});
});
-20
View File
@@ -150,31 +150,11 @@ function needsPinnedPort(config: RunConfig): boolean {
return config.isTest || config.args.some(isMavenTestGoal);
}
/**
* Whether the debugger can attach to the JVMs `config` starts. When it isn't installed, the
* debugger offers the install (then debugs again) and the build isn't started for nothing.
*/
function attachDebuggerReady(config: RunConfig, show?: boolean): Promise<boolean> {
return new Promise((resolve) => {
executeAction("debug.jvmAttachReady", {
module: config.module ?? config.cwd ?? "",
retry: () => void debugWithAgent(config, show),
done: resolve,
}).then(
(ran) => {
if (!ran) resolve(true);
},
() => resolve(true),
);
});
}
/** Runs `config` with a JDWP agent and attaches the Java debugger to every JVM it starts. */
export async function debugWithAgent(config: RunConfig, show?: boolean): Promise<boolean> {
const pinned = needsPinnedPort(config) ? await call(commands.debugFreePort()).catch(() => null) : null;
const agent = jdwpLaunch(config, pinned ?? undefined);
if (!agent) return false;
if (!(await attachDebuggerReady(config, show))) return true;
const launch = await withGradleAttachListener(agent);
let sessionId: string | null = null;
const waiting: number[] = [];
-1
View File
@@ -185,7 +185,6 @@ export function activate(): void | (() => void) {
icon: "toolwindow-find",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 5,
component: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindToolWindow)),
headerContent: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindTabs)),
@@ -10,7 +10,6 @@ import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride }
import {
ATTRIBUTES,
SCHEMES,
EFFECT_CSS,
overrideDeclarations,
effectiveScheme,
resolvedSchemeTokens,
@@ -124,8 +123,6 @@ function previewVars(
k.startsWith("--border-") ||
k.startsWith("--ansi-") ||
k.startsWith("--git-") ||
k.startsWith("--wave-") ||
k.startsWith("--editor-") ||
k === "--caret"
)
vars[k] = v;
@@ -140,32 +137,20 @@ function previewVars(
/** Text styled like an attribute in the preview. */
function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
const base = attr?.fg ?? attr?.effect;
if (!attr?.token) {
const colour: CSSProperties = {
if (!attr?.token)
return {
...(attr?.fg ? { color: `var(${attr.fg})` } : null),
...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null),
};
// Token-less attributes with a font-style renderer carry it on `--<fg|effect>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
if (!attr?.fontStyle || !base) return colour;
const effectColor = `var(${attr.effect ?? attr.fg ?? "--fg-muted"})`;
const effect: TextAttributesOverride["effect"] = attr.defaultEffect ?? (attr.effect ? "wave" : undefined);
const decoration =
effect && effect !== "none"
? `var(${attr.effect ?? base}-effect, ${EFFECT_CSS[effect]?.(effectColor) ?? "none"})`
: undefined;
return {
...colour,
fontWeight: `var(${base}-bold, ${
attr.defaultBold ? "var(--fw-code-bold, 700)" : "inherit"
})` as CSSProperties["fontWeight"],
fontStyle: `var(${base}-italic, ${attr.defaultItalic ? "italic" : "inherit"})`,
...(decoration
? { textDecoration: decoration, ...({ WebkitTextDecoration: decoration } as CSSProperties) }
// Token-less attributes with a font-style renderer carry it on `--<fg>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
...(attr?.fontStyle && (attr.fg ?? attr.effect)
? {
fontWeight: `var(${attr.fg ?? attr.effect}-bold, inherit)`,
fontStyle: `var(${attr.fg ?? attr.effect}-italic, inherit)`,
textDecoration: `var(${attr.effect ?? attr.fg}-effect, none)`,
}
: null),
};
}
if (attr.italicOnly)
return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` };
const t = attr.token;
@@ -221,11 +206,6 @@ const SAMPLES: Record<string, CodeSample> = {
[" ", "text"],
["legacyCall", "unused"],
],
[
["typo", "typo"],
[" ", "text"],
["deprecatedCall", "deprecated"],
],
[
["bad\u203dchar", "invalid"],
[" \u00b7 ", "whitespaces"],
-2
View File
@@ -5,7 +5,6 @@
display: flex;
flex-direction: column;
background: var(--bg-app);
outline: none;
}
.toolbar {
flex: none;
@@ -85,7 +84,6 @@
}
.message {
flex: 1;
outline: none;
display: flex;
flex-direction: column;
align-items: center;
-4
View File
@@ -541,10 +541,6 @@ export function MergeEditor({ input }: { input: EditorInput }) {
}, [actionHost]);
const focusProps = {
// WebKit doesn't focus a clicked button: focus then moves to the nearest focusable
// ancestor, which must be this editor for the footer and toolbar buttons to keep it the
// focused merge editor their actions run in.
tabIndex: -1,
onFocusCapture: (e: React.FocusEvent<HTMLDivElement>) => {
const target = e.target as Node;
if (oursRef.current?.contains(target)) paneRef.current = "left";
-10
View File
@@ -49,7 +49,6 @@ import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget, type UpdateTarget } from "./remoteUi";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "./comparePaths";
import { showCloneDialog } from "./clone";
import { createRepository } from "./createRepository";
import {
showManageRemotes,
showMergeDialog,
@@ -247,15 +246,6 @@ export function registerVcsActions(): () => void {
keyWhen: IN_COMMIT_WINDOW,
run: () => useCommit.setState((s) => ({ historyTick: s.historyTick + 1 })),
},
{
id: "vcs.createRepository",
title: "Create Git Repository…",
category: "Git",
icon: "branch",
when: "projectOpen && projectLocal",
keywords: ["init", "git", "enable version control"],
run: () => createRepository(),
},
{
id: "vcs.clone",
title: "Get from Version Control…",
-26
View File
@@ -1,26 +0,0 @@
/** VCS › Create Git Repository…: `git init` in a folder chosen under the project. */
import { open } from "@/platform/dialog";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { discoverRoots } from "./store";
export async function createRepository() {
const root = useProject.getState().project?.root;
const dir = await open({ title: tx("Select Directory for Git Init"), directory: true, defaultPath: root });
if (typeof dir !== "string") return;
try {
await call(commands.vcsInit(dir));
} catch (e) {
notifications().notify({
group: "vcs",
level: "error",
title: tx("Couldn't create Git repository"),
body: String((e as Error)?.message ?? e),
});
return;
}
notifications().notify({ group: "vcs", level: "info", title: tx`Created Git repository in ${dir}` });
await discoverRoots();
}
+1 -1
View File
@@ -266,7 +266,7 @@ export function statusOf(path: string): FileStatus | undefined {
}
/** Repositories found below a project that is not itself under git (scanned in the background). */
export async function discoverRoots() {
async function discoverRoots() {
const root = useVcs.getState().root;
if (!root || !isTauri()) return;
const roots = await call(commands.vcsRoots()).catch(() => []);
-40
View File
@@ -4,7 +4,6 @@ import {
ATTRIBUTES,
CLEAR_COLOR,
_setSchemeSource,
overrideDeclarations,
resolvedAttribute,
schemeAfterThemeChange,
schemeCss,
@@ -342,43 +341,4 @@ describe("color schemes", () => {
expect(v.codeStyle.languages.go).toEqual({ useTab: true });
expect(v.general.softWrapFiles).toContain("*.md");
});
it("styles Errors and Warnings attributes beyond colour", () => {
const decls = overrideDeclarations({
error: { bold: true, italic: true, effect: "strikeout", effectColor: "#ff0000" },
unused: { italic: true },
});
const map = new Map(decls);
expect(map.get("--wave-error-bold")).toBe("var(--fw-code-bold, 700)");
expect(map.get("--wave-error-italic")).toBe("italic");
expect(map.get("--wave-error-effect")).toBe("line-through #ff0000");
expect(map.get("--editor-error-decoration")).toBe("line-through #ff0000");
expect(map.get("--wave-error")).toBe("#ff0000");
expect(map.get("--fg-unused-italic")).toBe("italic");
const none = new Map(overrideDeclarations({ warning: { effect: "none" }, deprecated: { effect: "underline" } }));
expect(none.get("--editor-warning-decoration")).toBe("none");
expect(none.get("--fg-deprecated-effect")).toBe("underline var(--fg-deprecated)");
});
it("keeps the font style of Errors and Warnings in custom schemes", () => {
const customs = {
"custom-x": {
name: "X",
parent: "dark" as const,
attributes: { error: { bold: true }, deprecated: { effect: "underline" as const } },
},
};
const css = schemeCss("custom-x", undefined, null, customs);
expect(css).toContain("--wave-error-bold: var(--fw-code-bold, 700)");
expect(css).toContain("--fg-deprecated-effect: underline var(--fg-deprecated)");
});
it("lists typo and deprecated symbol under Errors and Warnings", () => {
const ids = ATTRIBUTES.filter((a) => a.path.join("/") === "General/Errors and Warnings").map((a) => a.id);
expect(ids).toEqual(["error", "warning", "weakWarning", "typo", "unused", "deprecated", "invalid"]);
expect(resolvedAttribute("dark", "typo")).toMatchObject({ effect: "wave" });
expect(resolvedAttribute("dark", "deprecated")).toMatchObject({ effect: "strikeout", effectColor: "#8c8f95" });
expect(resolvedAttribute("dark", "deprecated").foreground).toBeUndefined();
expect(resolvedAttribute("dark", "error")).toMatchObject({ effect: "wave", bold: false, italic: false });
});
});
+17 -70
View File
@@ -31,8 +31,7 @@ export interface AttributeDef {
token?: string;
/** A semantic modifier whose only configurable attribute is italic. */
italicOnly?: boolean;
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces,
* Errors and Warnings): overrides land on `--<fg|effect>-*`. */
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces). */
fontStyle?: true;
defaultBold?: boolean;
defaultItalic?: boolean;
@@ -140,46 +139,10 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
path: ["General", "Search Results"],
bg: "--bg-search-match-current",
},
{
id: "error",
name: "Error",
path: ["General", "Errors and Warnings"],
effect: "--wave-error",
defaultEffect: "wave",
fontStyle: true,
},
{
id: "warning",
name: "Warning",
path: ["General", "Errors and Warnings"],
effect: "--wave-warning",
defaultEffect: "wave",
fontStyle: true,
},
{ id: "error", name: "Error", path: ["General", "Errors and Warnings"], effect: "--wave-error" },
{ id: "warning", name: "Warning", path: ["General", "Errors and Warnings"], effect: "--wave-warning" },
{ id: "weakWarning", name: "Weak warning", path: ["General", "Errors and Warnings"], fg: "--fg-weak-warning" },
{
id: "typo",
name: "Typo",
path: ["General", "Errors and Warnings"],
effect: "--wave-typo",
defaultEffect: "wave",
fontStyle: true,
},
{
id: "unused",
name: "Unused symbol",
path: ["General", "Errors and Warnings"],
fg: "--fg-unused",
fontStyle: true,
},
{
id: "deprecated",
name: "Deprecated symbol",
path: ["General", "Errors and Warnings"],
effect: "--fg-deprecated",
defaultEffect: "strikeout",
fontStyle: true,
},
{ id: "unused", name: "Unused symbol", path: ["General", "Errors and Warnings"], fg: "--fg-unused" },
syn("invalid", "Bad character", ["General", "Errors and Warnings"], "invalid"),
syn("todo", "TODO defaults", ["General", "Code"], "todo"),
{ id: "inlineValue", name: "Inline debugger values", path: ["Debugger"], fg: "--syn-inline-value" },
@@ -376,11 +339,6 @@ export const ATTRIBUTES: readonly AttributeDef[] = [
/** Variables a scheme sets: every attribute variable plus the editor tokens derived from them. */
const SCHEME_VARS = new Set<string>([
...ATTRIBUTES.flatMap((a) => [a.fg, a.bg, a.effect].filter((v): v is string => !!v)),
// Font style of token-less attributes (`overrideDeclarations` writes them for custom schemes too).
...ATTRIBUTES.flatMap((a) => {
const base = a.fg ?? a.effect;
return !a.token && a.fontStyle && base ? [`${base}-bold`, `${base}-italic`, `${a.effect ?? base}-effect`] : [];
}),
"--syn-comment-style",
"--syn-doc-comment-tag-effect",
"--editor-error-decoration",
@@ -519,7 +477,7 @@ export function boldWeight(bold: boolean): string {
return bold ? "var(--fw-code-bold, 700)" : "var(--fw-code, 400)";
}
export const EFFECT_CSS: Record<string, (c: string) => string> = {
const EFFECT_CSS: Record<string, (c: string) => string> = {
underline: (c) => `underline ${c}`,
wave: (c) => `underline wavy ${c}`,
strikeout: (c) => `line-through ${c}`,
@@ -549,12 +507,9 @@ export function overrideDeclarations(
}
if (setOrCleared(o.background) && attr.bg) out.push([attr.bg, o.background]);
if (setOrCleared(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]);
if (attr.effect && (setOrCleared(o.effectColor) || o.effect)) {
const style = o.effect ?? attr.defaultEffect ?? "wave";
const color = setOrCleared(o.effectColor) ? o.effectColor! : `var(${attr.effect})`;
const decoration = EFFECT_CSS[style]?.(color) ?? "none";
if (attr.id === "error") out.push(["--editor-error-decoration", decoration]);
if (attr.id === "warning") out.push(["--editor-warning-decoration", decoration]);
if (attr.effect && setOrCleared(o.effectColor)) {
if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]);
if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]);
}
if (attr.token) {
const t = attr.token;
@@ -579,9 +534,7 @@ export function overrideDeclarations(
if (o.effect)
out.push([
`${attr.effect ?? base}-effect`,
EFFECT_CSS[o.effect]?.(
setOrCleared(o.effectColor) ? o.effectColor! : attr.effect ? `var(${attr.effect})` : "currentColor",
) ?? "none",
EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none",
]);
}
}
@@ -693,20 +646,14 @@ export function resolvedAttribute(
const italicCss =
vars.get(`--syn-${token}-italic`) ?? (token === "comment" ? vars.get("--syn-comment-style") : undefined);
out.italic = own?.italic ?? italicFromCss(italicCss) ?? attr.defaultItalic ?? false;
if (effect) out.effect = effect;
} else {
// Token-less attributes whose renderer applies font style (the Git segments, Errors and
// Warnings) carry their overrides on `--<fg|effect>-*`, the same declarations
// `overrideDeclarations` emits.
if (attr.fontStyle) {
const base = attr.fg ?? attr.effect;
const bold = base ? vars.get(`${base}-bold`) : undefined;
const italic = base ? vars.get(`${base}-italic`) : undefined;
out.bold = boldFromCss(bold) ?? attr.defaultBold ?? false;
out.italic = italicFromCss(italic) ?? attr.defaultItalic ?? false;
}
if (effect) out.effect = effect;
}
} else if (attr.fontStyle) {
const base = attr.fg ?? attr.effect;
const bold = base ? vars.get(`${base}-bold`) : undefined;
const italic = base ? vars.get(`${base}-italic`) : undefined;
if (bold !== undefined) out.bold = boldFromCss(bold) ?? false;
if (italic !== undefined) out.italic = italicFromCss(italic) ?? false;
} else if (effect) out.effect = effect;
if (token && effect) out.effect = effect;
return out;
}
+1 -12
View File
@@ -1510,16 +1510,5 @@
"{name} (group)": "{name} (group)",
"Servers in group": "Servers in group",
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
"Server Group": "Server Group",
"Create Git Repository…": "Create Git Repository…",
"Select Directory for Git Init": "Select Directory for Git Init",
"Couldn't create Git repository": "Couldn't create Git repository",
"Created Git repository in {dir}": "Created Git repository in {dir}",
"Check the JVM Debugger": "Check the JVM Debugger",
"Whether the debugger for a build tool's JVMs is installed, offering the install when not (arg: { module, retry, done(ready) })": "Whether the debugger for a build tool's JVMs is installed, offering the install when not (arg: { module, retry, done(ready) })",
"This module has Kotlin sources, so debugging it needs the Kotlin debugger (kotlin-debug-adapter).": "This module has Kotlin sources, so debugging it needs the Kotlin debugger (kotlin-debug-adapter).",
"Implements {names}": "Implements {names}",
"Choose Implemented Interface of {name} ({count} found)": "Choose Implemented Interface of {name} ({count} found)",
"Search tasks": "Search tasks",
"No matching tasks": "No matching tasks"
"Server Group": "Server Group"
}
+1 -12
View File
@@ -7911,16 +7911,5 @@
"{name} (group)": "{name}(分组)",
"Servers in group": "组内服务器",
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
"Server Group": "服务器分组",
"Create Git Repository…": "创建 Git 仓库…",
"Select Directory for Git Init": "选择要执行 Git Init 的目录",
"Couldn't create Git repository": "无法创建 Git 仓库",
"Created Git repository in {dir}": "已在 {dir} 创建 Git 仓库",
"Check the JVM Debugger": "检查 JVM 调试器",
"Whether the debugger for a build tool's JVMs is installed, offering the install when not (arg: { module, retry, done(ready) })": "检查构建工具启动的 JVM 所需的调试器是否已安装,未安装时提供安装(参数:{ module, retry, done(ready) })",
"This module has Kotlin sources, so debugging it needs the Kotlin debugger (kotlin-debug-adapter).": "此模块包含 Kotlin 源代码,调试它需要 Kotlin 调试器(kotlin-debug-adapter)。",
"Implements {names}": "实现 {names}",
"Choose Implemented Interface of {name} ({count} found)": "选择 {name} 实现的接口(找到 {count} 个)",
"Search tasks": "搜索任务",
"No matching tasks": "没有匹配的任务"
"Server Group": "服务器分组"
}
-5
View File
@@ -73,11 +73,6 @@ export function invalidateVirtualDocument(uri: string): void {
cache.delete(uri);
}
/** Forgets every cached document: a build tool reload may have replaced library jars in place. */
export function invalidateVirtualDocuments(): void {
cache.clear();
}
/** Contents of a virtual document (cached; failures are retried on the next call). */
export function virtualDocument(uri: string): Promise<VirtualDocument> {
let p = cache.get(uri);
-8
View File
@@ -204,8 +204,6 @@ html {
--fg-fold-marker: #6f737a;
--fg-whitespace: #6f737a;
--fg-unused: #6f737a;
--wave-typo: #59a869;
--fg-deprecated: #8c8f95;
--bg-identifier-under-caret: #373b39;
--bg-identifier-under-caret-write: #402f33;
--fg-inlay: #868a91;
@@ -645,8 +643,6 @@ html {
--fg-fold-marker: #8b8e94;
--fg-whitespace: #b5b7bd;
--fg-unused: #8c8c8c;
--wave-typo: #3d9950;
--fg-deprecated: #6c707e;
--bg-identifier-under-caret: #edebfc;
--bg-identifier-under-caret-write: #fce8f4;
--fg-inlay: #73767c;
@@ -939,8 +935,6 @@ html {
--fg-fold-marker: #9fa2a9;
--fg-whitespace: #8b8e95;
--fg-unused: #8b8e95;
--wave-typo: #6cbb7d;
--fg-deprecated: #b6b8be;
--bg-identifier-under-caret: #3f4a44;
--bg-identifier-under-caret-write: #553840;
--fg-inlay: #ccced3;
@@ -1153,8 +1147,6 @@ html {
--fg-fold-marker: var(--palette-muted);
--fg-whitespace: var(--palette-muted);
--fg-unused: var(--palette-muted);
--wave-typo: var(--palette-green);
--fg-deprecated: var(--palette-muted);
--fg-inlay: var(--palette-muted);
--fg-weak-warning: var(--palette-muted);
--syn-inline-value: var(--palette-muted);
-3
View File
@@ -344,9 +344,6 @@ export function MenuList({
aria-label={aria["aria-label"]}
className={styles.menu}
onKeyDown={onKeyDown}
// A right-click in an open menu does nothing; without this it reaches whatever is under the
// menu in the component tree, e.g. the title bar's toolbar menu behind File › Open Recent.
onContextMenu={(e) => e.preventDefault()}
onPointerMove={(e) => {
if (e.currentTarget.contains(e.target as Node)) pointerMove({ x: e.clientX, y: e.clientY });
}}
-11
View File
@@ -283,17 +283,6 @@ describe("Menu action enablement", () => {
expect(open).toHaveFocus();
});
it("keeps a right-click in a menu from reaching the components behind it", () => {
const behind = vi.fn((e: { defaultPrevented: boolean }) => e.defaultPrevented);
render(
<div onContextMenu={(e) => behind(e)}>
<MenuList items={[{ label: "Recent", run: () => undefined }]} onDone={() => {}} />
</div>,
);
fireEvent.contextMenu(screen.getByRole("menuitem", { name: "Recent" }));
expect(behind).toHaveReturnedWith(true);
});
it("reports a hovered row once and lets the keyboard continue from it", () => {
const active: string[] = [];
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => undefined });
+2 -8
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { useLayoutEffect, useMemo, useRef } from "react";
import { useMemo } from "react";
import clsx from "clsx";
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T, tooltipAttrs } from "@/ui";
import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions";
@@ -129,13 +129,7 @@ export function TaskStatus() {
export function BackgroundTasksHost() {
const open = useTaskPopup((s) => s.open);
const tasks = useTasks((s) => s.tasks);
// The last task ending (or cancelled from here) closes the popup: its status bar anchor goes away.
const hadTasks = useRef(tasks.length > 0);
useLayoutEffect(() => {
if (hadTasks.current && !tasks.length) useTaskPopup.setState({ open: false });
hadTasks.current = tasks.length > 0;
}, [tasks.length]);
const anchor = (tasks.length ? document.querySelector("[data-background-tasks]") : null) ?? {
const anchor = document.querySelector("[data-background-tasks]") ?? {
x: window.innerWidth - 16,
y: window.innerHeight - 16,
};
+1 -1
View File
@@ -95,7 +95,7 @@ function ProjectButton() {
);
}
const VCS_FALLBACK_ACTIONS = ["vcs.createRepository", "vcs.clone", "vcs.localHistory", "vcs.showLog"];
const VCS_FALLBACK_ACTIONS = ["vcs.clone", "vcs.localHistory", "vcs.showLog"];
/** "Version Control ▾" shown while no VCS widget is contributed (project without a repository). */
function VcsFallback() {
-5
View File
@@ -76,11 +76,6 @@ export interface ToolWindowContribution {
* or "onUse" — listed under the stripe's "…" until first opened (Find, Run…).
*/
stripe?: "shown" | "onUse";
/**
* Its content lasts only while the project is open (run and debug sessions, search results),
* so it isn't reopened with the project.
*/
sessionContent?: boolean;
}
export type StatusAlign = "left" | "right";
-17
View File
@@ -141,23 +141,6 @@ describe("cancelling", () => {
expect(off.parentElement).toHaveAttribute("data-tooltip", "This step can't be cancelled");
});
it("cancelling the last task from the popup closes it", () => {
const cancel = vi.fn(() => upsertTask({ id: "dl", title: "Installing", message: null, fraction: 1, done: true }));
upsertTask({ id: "dl", title: "Installing", message: null, fraction: null, done: false, cancel });
render(
<>
<TaskStatus />
<BackgroundTasksHost />
</>,
);
fireEvent.click(screen.getByRole("button", { name: /Background tasks/ }));
expect(screen.getByRole("dialog", { name: "Background tasks" })).toBeVisible();
act(() => fireEvent.click(screen.getByRole("button", { name: "Cancel Installing" })));
expect(useTasks.getState().tasks).toEqual([]);
expect(screen.queryByRole("dialog", { name: "Background tasks" })).toBeNull();
expect(screen.queryByText("No background tasks")).toBeNull();
});
it("indexing says why it can't be stopped", () => {
setIndexing(true, 10, 20);
expect(task("indexing")).toMatchObject({ uncancellable: expect.any(String) });
-23
View File
@@ -206,29 +206,6 @@ describe("tool window persistence", () => {
expect(useToolWindowLayout.getState().sizes.bottom).toBe(333);
});
it("doesn't reopen windows whose content ends with the session (Debug)", () => {
disposers.push(
registerToolWindow({
id: "debug",
title: "Debug",
icon: "debug",
anchor: "bottom",
sessionContent: true,
component: Empty,
}),
);
showToolWindow("structure");
showToolWindow("debug");
expect(isToolWindowVisible("debug")).toBe(true);
loadToolWindowLayout("/work/a");
expect(isToolWindowVisible("debug")).toBe(false);
expect(isToolWindowVisible("structure")).toBe(true);
// A layout saved before the window was marked.
localStorage.setItem("openidea.layout.v1:/work/d", JSON.stringify({ v: 2, visible: { bottom: "debug" } }));
loadToolWindowLayout("/work/d");
expect(isToolWindowVisible("debug")).toBe(false);
});
it("survives corrupt storage", () => {
localStorage.setItem("openidea.layout.v1:/work/c", "{not json");
loadToolWindowLayout("/work/c");
+2 -13
View File
@@ -251,7 +251,7 @@ function persist() {
distractionFreeStash,
} = useToolWindowLayout.getState();
// Distraction Free Mode is not saved: the layout to restore is.
const visible = withoutSessionWindows(distractionFreeStash ?? current);
const visible = distractionFreeStash ?? current;
try {
localStorage.setItem(
storageKey(scope),
@@ -341,17 +341,6 @@ function sanitizeVisible(raw: unknown): SlotRecord {
return out;
}
/** `visible` without the windows whose content doesn't outlive the project window (Run, Debug). */
function withoutSessionWindows(visible: SlotRecord): SlotRecord {
const windows = useContributions.getState().toolWindows;
const out = { ...visible };
for (const slot of SLOTS) {
const id = out[slot];
if (id && windows.get(id)?.sessionContent) out[slot] = null;
}
return out;
}
/**
* Moves visible windows recorded in an area they don't dock in (a layout
* saved before a window became split, or before split areas existed) to
@@ -378,7 +367,7 @@ export function loadToolWindowLayout(scope: string) {
const saved = readStorage(scope);
const version = typeof saved?.v === "number" ? saved.v : 1;
const placements = sanitizePlacements(saved?.placements, version);
let visible = withoutSessionWindows(sanitizeVisible(saved?.visible));
let visible = sanitizeVisible(saved?.visible);
visible = reconcile(visible, placements) ?? visible;
useToolWindowLayout.setState({
...initial(),