mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03086ce1dc | ||
|
|
3764d963d7 | ||
|
|
4d2bcbbc26 | ||
|
|
56d9c8908c | ||
|
|
0bd0c4e03b | ||
|
|
209fc796ce | ||
|
|
e7e89e46c2 | ||
|
|
c87ab87417 | ||
|
|
a9ad1f1bbb | ||
|
|
4c8caca6a5 | ||
|
|
2ec2e75d40 | ||
|
|
04d5d9bd58 | ||
|
|
9a415e6875 | ||
|
|
6f83f8cad0 | ||
|
|
b971c8b391 | ||
|
|
c333990404 | ||
|
|
70b953c77a | ||
|
|
cb18b88531 | ||
|
|
401d7a4105 | ||
|
|
46bd1f6fb8 | ||
|
|
85f755591b | ||
|
|
06b7513210 | ||
|
|
09aa03bb86 | ||
|
|
317988cfa7 | ||
|
|
7db2e98269 | ||
|
|
fdc8bc2d4c | ||
|
|
497d4766d7 |
No files matched your search
Generated
+1
@@ -4288,6 +4288,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
The IDE you know, rebuilt in Rust.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
@@ -19,8 +23,6 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<a href="#sponsor">Sponsor</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -70,17 +72,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Sponsor
|
||||
|
||||
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
|
||||
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
你熟悉的 IDE,用 Rust 重新打造。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<a href="#赞助">赞助</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
|
||||
为核心的原生桌面应用里。
|
||||
|
||||
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
|
||||
提供 macOS、Windows 和 Linux 版本。
|
||||
|
||||
## 性能对比
|
||||
|
||||
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
|
||||
取 5 次运行的中位数:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| --------------------- | -------------- | ---------------------- |
|
||||
| 显示项目窗口 | **0.9 s** | 3.1 s |
|
||||
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
|
||||
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
|
||||
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
|
||||
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
|
||||
| 退出 | **0.3 s** | 5.9 s |
|
||||
|
||||
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
|
||||
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
|
||||
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
|
||||
|
||||
## 插件
|
||||
|
||||
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
|
||||
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
|
||||
|
||||
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
|
||||
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
|
||||
|
||||
## 社区
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
|
||||
- 微信群,扫码加入:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
|
||||
|
||||
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
|
||||
|
||||
## 赞助
|
||||
|
||||
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
|
||||
|
||||
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
|
||||
@@ -1097,6 +1097,8 @@ impl AgentManager {
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
// Copying may have started the source's agent: it stops again once idle.
|
||||
source.release_when_idle();
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), task.clone());
|
||||
task.persist();
|
||||
@@ -1166,6 +1168,8 @@ impl AgentManager {
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
// Undoing may have started the agent: it stops again once idle.
|
||||
task.release_when_idle();
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
|
||||
@@ -583,11 +583,12 @@ impl Shim {
|
||||
impl super::Shim for Shim {
|
||||
fn attach(&self, client: Peer) {
|
||||
let _ = self.client.set(client.clone());
|
||||
// The client went away: so do the processes.
|
||||
let weak = self.me.clone();
|
||||
// The client went away: so do the processes. Held until then: the connection drops
|
||||
// its handler (this shim) as it closes, and the sessions would go without stopping.
|
||||
let me = self.me.upgrade();
|
||||
tokio::spawn(async move {
|
||||
client.closed().await;
|
||||
if let Some(me) = weak.upgrade() {
|
||||
if let Some(me) = me {
|
||||
me.closed.store(true, Ordering::Release);
|
||||
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||
for s in sessions {
|
||||
@@ -598,6 +599,15 @@ impl super::Shim for Shim {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
/// A session dropped without being stopped (the shim went away) takes its process along.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.st.get_mut().proc.take() {
|
||||
p.kill.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn shim(&self) -> Option<Arc<Shim>> {
|
||||
self.shim.upgrade()
|
||||
|
||||
@@ -311,6 +311,8 @@ struct Replay {
|
||||
sent: Arc<Mutex<Vec<Vec<Value>>>>,
|
||||
launched: Mutex<Vec<Vec<String>>>,
|
||||
errors: Arc<Mutex<Vec<String>>>,
|
||||
/// Each process's kill token.
|
||||
kills: Mutex<Vec<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Cli for Replay {
|
||||
@@ -331,6 +333,7 @@ impl Cli for Replay {
|
||||
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
|
||||
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
|
||||
let kill = CancellationToken::new();
|
||||
self.kills.lock().push(kill.clone());
|
||||
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
|
||||
sent.lock().push(Vec::new());
|
||||
tokio::spawn(async move {
|
||||
@@ -428,6 +431,7 @@ 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();
|
||||
@@ -461,6 +465,23 @@ 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;
|
||||
@@ -727,6 +748,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
@@ -770,6 +792,7 @@ async fn a_failed_turn_reports_its_failure() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
|
||||
@@ -2391,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
|
||||
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id));
|
||||
mgr.rollback(&task.id, 1).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
|
||||
mgr.fork(&task.id, None).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
|
||||
mgr.shutdown();
|
||||
|
||||
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
|
||||
|
||||
@@ -77,6 +77,8 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
|
||||
@@ -28,6 +28,9 @@ tracing.workspace = true
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
+195
-12
@@ -1,6 +1,7 @@
|
||||
//! Attach to Process: the current user's processes, grouped by the debugger that can
|
||||
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
|
||||
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
|
||||
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
|
||||
//! natively (LLDB).
|
||||
|
||||
use crate::adapter;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -14,6 +15,7 @@ pub enum AttachKind {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Dotnet,
|
||||
Go,
|
||||
Native,
|
||||
}
|
||||
@@ -95,13 +97,29 @@ fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
|
||||
pub fn is_dotnet(exe: &str) -> bool {
|
||||
let path = Path::new(exe);
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
|
||||
if stem == "dotnet" {
|
||||
return true;
|
||||
}
|
||||
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
|
||||
}
|
||||
|
||||
fn is_python(name: &str) -> bool {
|
||||
let n = name.strip_suffix(".exe").unwrap_or(name);
|
||||
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
}
|
||||
|
||||
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
|
||||
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
|
||||
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
|
||||
/// interpreter claims.
|
||||
pub fn classify(
|
||||
p: &RawProcess,
|
||||
is_dotnet: impl Fn(&str) -> bool,
|
||||
is_go: impl Fn(&str) -> bool,
|
||||
native_adapter: &str,
|
||||
) -> Option<AttachableProcess> {
|
||||
let name = file_name(&p.exe).to_owned();
|
||||
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
|
||||
let (host, port) = match addr {
|
||||
@@ -129,6 +147,9 @@ pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &s
|
||||
if is_python(&name) {
|
||||
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
|
||||
}
|
||||
if is_dotnet(&p.exe) {
|
||||
return make(AttachKind::Dotnet, "netcoredbg", None);
|
||||
}
|
||||
if is_go(&p.exe) {
|
||||
return make(AttachKind::Go, "delve", None);
|
||||
}
|
||||
@@ -143,7 +164,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
|
||||
pub fn is_go_binary(exe: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(exe) else { return false };
|
||||
@@ -165,6 +186,17 @@ pub fn is_go_binary(exe: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
|
||||
/// system locations skipped on Unix.
|
||||
fn is_windows_system(exe: &str) -> bool {
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
lower.get(1..).is_some_and(|rest| {
|
||||
rest.starts_with(":\\windows\\")
|
||||
|| rest.starts_with(":\\program files\\")
|
||||
|| rest.starts_with(":\\program files (x86)\\")
|
||||
})
|
||||
}
|
||||
|
||||
/// The current user's processes (without this one).
|
||||
#[cfg(unix)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
@@ -181,7 +213,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
let (pid, rest) = line.split_once(char::is_whitespace)?;
|
||||
Some((pid.parse().ok()?, rest.trim().to_owned()))
|
||||
};
|
||||
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
|
||||
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
|
||||
let exes: HashMap<u32, String> =
|
||||
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
|
||||
let me = std::process::id();
|
||||
@@ -190,9 +222,8 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.filter_map(split)
|
||||
.filter(|(pid, _)| *pid != me)
|
||||
.map(|(pid, args)| {
|
||||
let exe = exes
|
||||
.get(&pid)
|
||||
.cloned()
|
||||
let exe = exe_path(pid)
|
||||
.or_else(|| exes.get(&pid).cloned())
|
||||
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
|
||||
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
|
||||
RawProcess { pid, exe, args }
|
||||
@@ -200,9 +231,134 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
/// The processes this user may open (without this one): system and other users'
|
||||
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
|
||||
#[cfg(windows)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
|
||||
windows_processes::list()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_processes {
|
||||
use super::RawProcess;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
|
||||
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
|
||||
};
|
||||
|
||||
pub fn list() -> IdeResult<Vec<RawProcess>> {
|
||||
let me = std::process::id();
|
||||
Ok(pids()?
|
||||
.into_iter()
|
||||
.filter(|&pid| pid != 0 && pid != me)
|
||||
.filter_map(|pid| {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned here.
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
let exe = image_path(&process)?;
|
||||
let args = command_line(&process).unwrap_or_else(|| exe.clone());
|
||||
Some(RawProcess { pid, exe, args })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn pids() -> IdeResult<Vec<u32>> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"cannot list processes: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
let snapshot = OwnedHandle::from_raw_handle(snapshot);
|
||||
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
|
||||
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
|
||||
while more != 0 {
|
||||
out.push(entry.th32ProcessID);
|
||||
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn image_path(process: &OwnedHandle) -> Option<String> {
|
||||
let mut buf = vec![0u16; 32 * 1024];
|
||||
let mut len = buf.len() as u32;
|
||||
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
|
||||
let ok = unsafe {
|
||||
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
|
||||
};
|
||||
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
|
||||
}
|
||||
|
||||
/// The command line (Windows 8.1+), read with the same limited query access.
|
||||
fn command_line(process: &OwnedHandle) -> Option<String> {
|
||||
let mut size = 0u32;
|
||||
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
|
||||
unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if (size as usize) < size_of::<UNICODE_STRING>() {
|
||||
return None;
|
||||
}
|
||||
// u64 elements keep the UNICODE_STRING header aligned.
|
||||
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
|
||||
// SAFETY: a buffer of at least `size` bytes.
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
buf.as_mut_ptr().cast(),
|
||||
size,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if status < 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
|
||||
let text = unsafe {
|
||||
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
|
||||
if s.Buffer.is_null() {
|
||||
return None;
|
||||
}
|
||||
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
|
||||
};
|
||||
Some(String::from_utf16_lossy(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// The full executable path: `comm` keeps a relative path a process was started with.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn exe_path(pid: u32) -> Option<String> {
|
||||
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
|
||||
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
|
||||
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn exe_path(_pid: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Node's default inspector port, which `SIGUSR1` opens.
|
||||
@@ -244,7 +400,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
|
||||
let cwd = std::env::temp_dir();
|
||||
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
|
||||
let mut out: Vec<AttachableProcess> =
|
||||
list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect();
|
||||
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
|
||||
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
@@ -258,7 +414,8 @@ mod tests {
|
||||
}
|
||||
|
||||
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
|
||||
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
|
||||
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
|
||||
.map(|p| (p.kind, p.port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -292,11 +449,37 @@ mod tests {
|
||||
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
|
||||
Some((AttachKind::Python, None))
|
||||
);
|
||||
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
|
||||
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
|
||||
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
|
||||
assert!(!is_python("pythonista"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dotnet_hosts_and_apphosts() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let app = dir.join("Api");
|
||||
std::fs::write(&app, b"").unwrap();
|
||||
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
|
||||
let tool = dir.join("tool");
|
||||
std::fs::write(&tool, b"").unwrap();
|
||||
assert!(is_dotnet(app.to_str().unwrap()));
|
||||
assert!(!is_dotnet(tool.to_str().unwrap()));
|
||||
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
|
||||
assert!(is_dotnet("dotnet"));
|
||||
assert!(!is_dotnet("Api"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_windows_system_programs() {
|
||||
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
|
||||
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
|
||||
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
|
||||
assert!(!is_windows_system("/usr/bin/true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_go_binaries_by_buildinfo() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
|
||||
|
||||
@@ -557,6 +557,17 @@ async fn attach_to_processes() {
|
||||
extra: |pid, _| serde_json::json!({"pid": pid}),
|
||||
},
|
||||
];
|
||||
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
|
||||
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
|
||||
if dotnet_app.is_file() {
|
||||
cases.push(AttachCase {
|
||||
kind: attach::AttachKind::Dotnet,
|
||||
command: vec![s(&dotnet_app)],
|
||||
source: dir.join("dotnet/Program.cs"),
|
||||
line: 1,
|
||||
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
|
||||
});
|
||||
}
|
||||
if java_server.is_some() {
|
||||
cases.insert(
|
||||
0,
|
||||
|
||||
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -330,21 +331,61 @@ pub async fn build_sync(
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let lsp = project.lsp.clone();
|
||||
let documents = project.documents.clone();
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
let first = updates.first().cloned();
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
let changed = {
|
||||
let lsp = lsp.clone();
|
||||
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
|
||||
};
|
||||
if let Some(file) = first
|
||||
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
|
||||
{
|
||||
tracing::debug!("openidea.java.refreshArchives: {e}");
|
||||
}
|
||||
refresh_library_classes(&lsp, &documents).await;
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
|
||||
/// again keeps its path) show the new contents, like a reopened one.
|
||||
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
|
||||
let changed = {
|
||||
let (lsp, documents) = (lsp.clone(), documents.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
|
||||
.filter(|(_, uri)| lsp.library_class_changed(uri))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (doc, uri) in changed {
|
||||
match lsp.virtual_document(&uri).await {
|
||||
Ok(vd) => {
|
||||
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
|
||||
tracing::debug!("refreshing {uri}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
@@ -1051,7 +1092,6 @@ mod maven_completion_tests {
|
||||
use ide_build::maven::completion::PomCompletionKind;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_workspace::dependencies::DependencyVersionSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cache answers the test plants before completing, so the op's Central
|
||||
/// lookups hit the session cache and the network is never reached.
|
||||
|
||||
@@ -809,6 +809,21 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
|
||||
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
|
||||
/// from the old archive. Best-effort: skipped while jdtls is not already running.
|
||||
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
|
||||
let java = LanguageId::new("java");
|
||||
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
|
||||
return Ok(());
|
||||
};
|
||||
if server.running().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
|
||||
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
/// Modification time of the archive behind a cached library class, so a jar
|
||||
/// replaced in place (a SNAPSHOT installed again) is read again.
|
||||
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
@@ -508,6 +511,7 @@ impl LspManager {
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
virtual_stamps: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
@@ -3202,14 +3206,44 @@ impl LspManager {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether the archive behind a cached library class changed since it was read.
|
||||
/// Stats the archive: call from a blocking worker.
|
||||
pub fn library_class_changed(&self, uri: &str) -> bool {
|
||||
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
|
||||
virtual_doc::archive_modified(uri) != Some(stamp)
|
||||
}
|
||||
|
||||
/// Archives behind cached library classes that changed since they were read.
|
||||
/// Stats each archive: call from a blocking worker.
|
||||
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
|
||||
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
|
||||
let mut archives: Vec<PathBuf> = uris
|
||||
.iter()
|
||||
.filter(|uri| self.library_class_changed(uri))
|
||||
.filter_map(|uri| virtual_doc::archive(uri))
|
||||
.collect();
|
||||
archives.sort();
|
||||
archives.dedup();
|
||||
archives
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
let cached = self.virtual_docs.lock().get(uri).cloned();
|
||||
if let Some(d) = cached {
|
||||
let stamp = self.virtual_stamps.lock().get(uri).copied();
|
||||
let current = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
|
||||
};
|
||||
if stamp.is_none() || stamp == current {
|
||||
return Ok(d);
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
@@ -3353,11 +3387,18 @@ impl LspManager {
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let stamp = virtual_doc::archive_modified(uri);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
let mut stamps = self.virtual_stamps.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
stamps.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
match stamp {
|
||||
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
|
||||
None => stamps.remove(uri),
|
||||
};
|
||||
doc
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,20 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive(uri: &str) -> Option<std::path::PathBuf> {
|
||||
match scheme(uri) {
|
||||
"jar" => Some(crate::library_source::parse(uri)?.jar),
|
||||
"jdt" => description(uri).filter(|p| p.ends_with(".jar") || p.ends_with(".jmod")).map(Into::into),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modification time of the archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive_modified(uri: &str) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(archive(uri)?).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
@@ -304,6 +318,29 @@ mod tests {
|
||||
assert_eq!(description("foo://x/y"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_archive_behind_a_library_class() {
|
||||
assert_eq!(archive(JDT), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar".into()));
|
||||
assert_eq!(
|
||||
archive("jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(archive("jdt://contents/a.jar/p/C.class"), None);
|
||||
assert_eq!(archive("file:///a/C.java"), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jar = dir.path().join("lib-1.0-SNAPSHOT.jar");
|
||||
std::fs::write(&jar, b"PK").unwrap();
|
||||
let uri = format!("jar:file://{}!/p/C.class", jar.display());
|
||||
assert_eq!(archive(&uri), Some(jar.clone()));
|
||||
let before = archive_modified(&uri).unwrap();
|
||||
let file = std::fs::File::options().write(true).open(&jar).unwrap();
|
||||
file.set_modified(before + std::time::Duration::from_secs(5)).unwrap();
|
||||
assert_ne!(archive_modified(&uri), Some(before));
|
||||
std::fs::remove_file(&jar).unwrap();
|
||||
assert_eq!(archive_modified(&uri), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
|
||||
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.13.0",
|
||||
"1.14.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -268,6 +270,7 @@ by hand:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -177,6 +177,8 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -239,6 +241,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -174,6 +174,8 @@ for (const [theme, label] of [
|
||||
const surface = target.locator("..");
|
||||
const other = tab(page, "Result 3");
|
||||
const otherSurface = other.locator("..");
|
||||
// The results hold focus, as they do at the end when the menu is opened from a result tab.
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.mouse.move(0, 0);
|
||||
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const selectedColor = await tab(page, "Result 2")
|
||||
@@ -227,6 +229,43 @@ for (const [theme, label] of [
|
||||
});
|
||||
}
|
||||
|
||||
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const token = (name: string) =>
|
||||
page.evaluate((name) => {
|
||||
const probe = document.body.appendChild(document.createElement("div"));
|
||||
probe.style.background = `var(${name})`;
|
||||
const color = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, name);
|
||||
const accent = await token("--bg-tab-selected");
|
||||
const inactive = await token("--bg-tab-selected-inactive");
|
||||
expect(inactive).not.toBe(accent);
|
||||
const surfaces = [
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
|
||||
page.getByRole("tab", { name: "Database", exact: true }),
|
||||
tab(page, "Result 2"),
|
||||
].map((tab) => tab.locator(".."));
|
||||
const colors = () =>
|
||||
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
|
||||
|
||||
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
// A menu opened from a result tab keeps the results active.
|
||||
await tab(page, "Result 2").click({ button: "right" });
|
||||
await expect(menu(page)).toBeVisible();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
|
||||
await page.locator(".cm-content").first().click();
|
||||
await expect.poll(colors).toEqual([accent, inactive, inactive]);
|
||||
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
|
||||
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
|
||||
});
|
||||
|
||||
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
@@ -512,3 +551,51 @@ test("console runs replace their unpinned results; pinned results stay until Clo
|
||||
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
|
||||
await expect(strip).toHaveText(["Output", result(2)]);
|
||||
});
|
||||
|
||||
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const grid = page.getByRole("grid");
|
||||
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
|
||||
await expect(cell).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await expect(cell).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(tab(page, "Result 2")).toBeFocused();
|
||||
// The toolbar is entered at the control used last.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
|
||||
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).not.toBeFocused();
|
||||
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
|
||||
|
||||
// The keyboard path to the header's local filter is the Local Filter action.
|
||||
await grid.focus();
|
||||
await page.evaluate(async () => {
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("db.localFilter");
|
||||
});
|
||||
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
|
||||
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filter).toBeHidden();
|
||||
await expect(grid).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test.use({ video: "on" });
|
||||
|
||||
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/dev/acme-api/README.md";
|
||||
const probe = new Promise((resolve) => {
|
||||
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
|
||||
});
|
||||
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
|
||||
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
|
||||
const keymap = "/src/platform/keymap.ts";
|
||||
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
|
||||
const actions = "/src/platform/actions.ts";
|
||||
await (
|
||||
await import(/* @vite-ignore */ actions)
|
||||
).executeAction("diff.compareTexts", {
|
||||
path,
|
||||
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
|
||||
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
|
||||
});
|
||||
});
|
||||
|
||||
const pane = page.locator(".cm-merge-b .cm-content");
|
||||
await pane.click();
|
||||
await expect(pane).toBeFocused();
|
||||
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
|
||||
await expect(jump).toHaveCount(0);
|
||||
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
|
||||
await expect(jump).toBeVisible();
|
||||
await expect(pane).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Meta+ArrowDown");
|
||||
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
|
||||
await expect(pane).toBeHidden();
|
||||
});
|
||||
@@ -122,6 +122,23 @@ test("Apply writes and stages unresolved text only after confirmation", async ({
|
||||
expect((await writes(page))[0]!.args[2]).toEqual({ changes: [{ from: 0, to: 13, insert: BASE }] });
|
||||
});
|
||||
|
||||
test("footer buttons work when a click doesn't focus them, as in WebKit", async ({ page }) => {
|
||||
await mountMerge(page);
|
||||
await result(page).locator(".cm-content").click();
|
||||
// WebKit focuses the clicked button's nearest focusable ancestor, not the button.
|
||||
await page
|
||||
.locator("#merge-host")
|
||||
.getByRole("button", { name: "Apply", exact: true })
|
||||
.evaluate((button) => {
|
||||
let target = button.parentElement;
|
||||
while (target && !target.hasAttribute("tabindex")) target = target.parentElement;
|
||||
if (target) target.focus();
|
||||
else (document.activeElement as HTMLElement | null)?.blur();
|
||||
(button as HTMLButtonElement).click();
|
||||
});
|
||||
await expect(page.getByRole("dialog")).toContainText("Save and mark the file as resolved anyway?");
|
||||
});
|
||||
|
||||
test("binary and unreadable versions show an error with no Apply controls", async ({ page }) => {
|
||||
await mountMerge(page, ["\0base", "\0ours", "\0theirs"]);
|
||||
await expect(page.locator("#merge-host")).toContainText("Binary files cannot be resolved in the merge editor.");
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.13.0
|
||||
Bundle-Version: 1.14.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
Binary file not shown.
@@ -17,6 +17,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.archives.RefreshArchivesCommand">
|
||||
<command id="openidea.java.refreshArchives"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ai.openidea.archives;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IPath;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.core.runtime.Path;
|
||||
import org.eclipse.jdt.core.IJavaModel;
|
||||
import org.eclipse.jdt.core.IJavaProject;
|
||||
import org.eclipse.jdt.core.IPackageFragmentRoot;
|
||||
import org.eclipse.jdt.core.JavaCore;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
|
||||
/**
|
||||
* Re-reads library archives replaced in place (a SNAPSHOT installed again keeps its path). The
|
||||
* arguments are the archives' file system paths. Their package fragment roots are closed, which
|
||||
* drops the class files read from the old archive, and jdtls' decompiled sources (cached by class
|
||||
* handle, which a replaced archive keeps) are dropped, so the classes are read again.
|
||||
*/
|
||||
public final class RefreshArchivesCommand implements IDelegateCommandHandler {
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
IJavaModel model = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot());
|
||||
model.refreshExternalArchives(null, monitor);
|
||||
Set<IPath> archives = new HashSet<>();
|
||||
for (Object argument : arguments) {
|
||||
if (argument instanceof String path) archives.add(Path.fromOSString(path));
|
||||
}
|
||||
int closed = 0;
|
||||
if (archives.isEmpty()) return closed;
|
||||
clearDecompilerCache();
|
||||
for (IJavaProject project : model.getJavaProjects()) {
|
||||
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
if (root.isArchive() && root.isExternal() && archives.contains(root.getPath()) && root.isOpen()) {
|
||||
root.close();
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** jdtls keeps decompiled sources in a static per-decompiler LRU with no API to drop them. */
|
||||
private static void clearDecompilerCache() {
|
||||
try {
|
||||
Class<?> impl = IDelegateCommandHandler.class.getClassLoader()
|
||||
.loadClass("org.eclipse.jdt.ls.core.internal.decompiler.DecompilerImpl");
|
||||
Field field = impl.getDeclaredField("decompilerCache");
|
||||
field.setAccessible(true);
|
||||
if (field.get(null) instanceof Map<?, ?> caches) {
|
||||
synchronized (caches) {
|
||||
for (Object cache : caches.values()) {
|
||||
if (cache instanceof Map<?, ?> map) map.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
JavaLanguageServerPlugin.logException("Clearing decompiled sources", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -262,6 +264,7 @@ from `type`, then map:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
+11
-1
@@ -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" | "go" | "native";
|
||||
export type AttachKind = "java" | "node" | "python" | "dotnet" | "go" | "native";
|
||||
|
||||
export type AttachableProcess = {
|
||||
pid: number,
|
||||
@@ -4090,6 +4090,14 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4133,6 +4141,8 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
|
||||
interface SourceState {
|
||||
@@ -82,6 +82,9 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project !== previous.project) clear();
|
||||
});
|
||||
// A reload may have replaced a library jar in place (a SNAPSHOT installed again): reopened
|
||||
// classes fetch their contents again (the backend refreshes the open ones).
|
||||
const offRoots = onIdeEvent("projectRootsChanged", () => invalidateVirtualDocuments());
|
||||
const offActions = registerActions([
|
||||
{
|
||||
id: "editor.downloadSources",
|
||||
@@ -118,6 +121,7 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
return () => {
|
||||
offActions();
|
||||
offProject();
|
||||
offRoots();
|
||||
clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,6 +367,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
tabPrefs.placement === "right" && styles.tabsRight,
|
||||
)}
|
||||
data-pane-id={paneId}
|
||||
data-focus-scope=""
|
||||
aria-label={tx("Editor")}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
@@ -106,6 +106,12 @@ const lensField = StateField.define<CodeLens[]>({
|
||||
const usagesEnabled = () => editorPrefs().inlay.codeVisionUsages;
|
||||
const authorEnabled = () => editorPrefs().inlay.codeVisionAuthor;
|
||||
|
||||
/** The author hint opens the file's blame annotations, and leaves them open when shown. */
|
||||
function showAnnotations(view: EditorView) {
|
||||
view.focus();
|
||||
if (!getAction("vcs.annotate")?.toggled?.()) void executeAction("vcs.annotate");
|
||||
}
|
||||
|
||||
function openUsages(view: EditorView, offset: number) {
|
||||
view.dispatch({ selection: EditorSelection.cursor(offset), userEvent: "select.lens" });
|
||||
view.focus();
|
||||
@@ -258,6 +264,7 @@ class UsagesLensPlugin {
|
||||
icon: "user",
|
||||
order: 20,
|
||||
kind: "author",
|
||||
run: showAnnotations,
|
||||
});
|
||||
const n = usages ? this.cache.get(key(t)) : undefined;
|
||||
if (n === undefined || (n === 0 && !editorPrefs().inlay.codeVisionNoUsages)) continue;
|
||||
|
||||
@@ -235,12 +235,16 @@ const lintTheme = EditorView.theme({
|
||||
".cm-lintRange-error": {
|
||||
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
WebkitTextDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
fontWeight: "var(--wave-error-bold, inherit)",
|
||||
fontStyle: "var(--wave-error-italic, inherit)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
},
|
||||
".cm-lintRange-warning": {
|
||||
textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
|
||||
WebkitTextDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
|
||||
fontWeight: "var(--wave-warning-bold, inherit)",
|
||||
fontStyle: "var(--wave-warning-italic, inherit)",
|
||||
backgroundColor: "var(--editor-warning-background, transparent)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
@@ -254,8 +258,10 @@ const lintTheme = EditorView.theme({
|
||||
".cm-lintRange-hint": { textDecoration: "none" },
|
||||
// Typo level: IDEA's green wavy underline.
|
||||
".cm-lintRange.cm-lsp-typo": {
|
||||
textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
|
||||
WebkitTextDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
|
||||
textDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
|
||||
WebkitTextDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
|
||||
fontWeight: "var(--wave-typo-bold, inherit)",
|
||||
fontStyle: "var(--wave-typo-italic, inherit)",
|
||||
textDecorationSkipInk: "none",
|
||||
textUnderlineOffset: "3px",
|
||||
},
|
||||
@@ -263,9 +269,16 @@ const lintTheme = EditorView.theme({
|
||||
textDecoration: "none",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--fg-unused, var(--fg-faint))",
|
||||
fontWeight: "var(--fg-unused-bold, inherit)",
|
||||
fontStyle: "var(--fg-unused-italic, inherit)",
|
||||
},
|
||||
".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" },
|
||||
".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" },
|
||||
".cm-lintRange.cm-lsp-deprecated": {
|
||||
textDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
|
||||
WebkitTextDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
|
||||
fontWeight: "var(--fg-deprecated-bold, inherit)",
|
||||
fontStyle: "var(--fg-deprecated-italic, inherit)",
|
||||
},
|
||||
// An error or warning on the same range (merged into one mark) keeps its underline.
|
||||
".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": {
|
||||
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
|
||||
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
|
||||
data: [
|
||||
{
|
||||
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
|
||||
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
|
||||
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -67,5 +67,27 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
|
||||
const after = view.state.facet(codeLenses);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!.pos).toBe(posBefore + 1);
|
||||
expect(after[0]!.title).toBe("1 reference");
|
||||
expect(after[0]!.title).toBe("Run test");
|
||||
});
|
||||
|
||||
it("leaves reference counts to Code Vision's usages", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lens = (line: number, title: string) => ({
|
||||
range: { start: { line, col: 0 }, end: { line, col: 1 } },
|
||||
command: { title, command: "csharp.showReferences", arguments: [] },
|
||||
});
|
||||
ipc.lspCodeLens.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
|
||||
});
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "a\nb\nc\nd",
|
||||
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import { call, commands } from "@/platform/ipc";
|
||||
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
|
||||
import { posToOffset } from "./positions";
|
||||
import { onLspRefresh, onServerSettled } from "./availability";
|
||||
/**
|
||||
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
|
||||
* Usages shows the same count above the declaration, under its own setting.
|
||||
*/
|
||||
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
|
||||
|
||||
const set = StateEffect.define<CodeLens[]>();
|
||||
const field = StateField.define<CodeLens[]>({
|
||||
create: () => [],
|
||||
@@ -62,7 +68,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
|
||||
view.dispatch({
|
||||
effects: set.of(
|
||||
result.flatMap((l) =>
|
||||
l.command
|
||||
l.command && !REFERENCE_COUNT.test(l.command.title)
|
||||
? [
|
||||
{
|
||||
pos: posToOffset(text, l.range.start),
|
||||
|
||||
@@ -39,6 +39,7 @@ import { gutterIconMarkup } from "@/ui";
|
||||
import {
|
||||
MARKER_ICONS,
|
||||
candidateKey,
|
||||
implementsMarker,
|
||||
inheritorsMarker,
|
||||
markerCandidates,
|
||||
supersMarker,
|
||||
@@ -167,10 +168,13 @@ export async function fillMarkers(
|
||||
groups.has("inheritors") && !cache.unsupported.has("inheritors")
|
||||
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
|
||||
: [];
|
||||
const up =
|
||||
groups.has("supers") && !cache.unsupported.has("supers")
|
||||
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === 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)
|
||||
: [];
|
||||
|
||||
let implementations: Location[][] = [];
|
||||
const known = down.length ? await fromHierarchy(path, down, deps.supers) : new Map<MarkerCandidate, Location[]>();
|
||||
@@ -206,13 +210,26 @@ 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,7 +498,9 @@ class MarkersPlugin {
|
||||
if (stale()) return;
|
||||
entry.outline = {
|
||||
items,
|
||||
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
|
||||
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : ""), {
|
||||
implicitInterfaces: ctx.language === "go",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { candidates, items } = entry.outline;
|
||||
|
||||
@@ -125,7 +125,34 @@ 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; }", "}"],
|
||||
@@ -158,6 +185,17 @@ 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, () => "");
|
||||
@@ -228,6 +266,31 @@ 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(
|
||||
{
|
||||
|
||||
@@ -38,6 +38,11 @@ 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 {
|
||||
@@ -54,6 +59,8 @@ 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"]);
|
||||
|
||||
@@ -88,12 +95,30 @@ 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({
|
||||
@@ -136,7 +161,10 @@ 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}_$]*)/u;
|
||||
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 FENCES = [`"""`, `'''`];
|
||||
|
||||
@@ -166,13 +194,15 @@ 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]!;
|
||||
if (m && !/^\s*(\*|\/\*)/.test(line)) container = (m[1] ?? m[2])!;
|
||||
}
|
||||
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;
|
||||
@@ -222,6 +252,27 @@ 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);
|
||||
|
||||
@@ -180,6 +180,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.taskSearch {
|
||||
flex: none;
|
||||
margin: 6px 8px 4px;
|
||||
}
|
||||
.history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -391,6 +391,32 @@ 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();
|
||||
|
||||
@@ -5,9 +5,11 @@ 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,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
type IconName,
|
||||
type TreeHandle,
|
||||
} from "@/ui";
|
||||
import {
|
||||
SESSION_KIND,
|
||||
@@ -37,6 +40,7 @@ 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";
|
||||
@@ -187,6 +191,8 @@ 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(
|
||||
() =>
|
||||
@@ -201,20 +207,32 @@ 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()];
|
||||
const all = [...tasks.values()].filter((t) => !matches || matches.has(t.id));
|
||||
const pinned = pinnedMembers(all, organize);
|
||||
return [
|
||||
...(pinned.length ? [{ kind: "pinned", tasks: pinned } as Node] : []),
|
||||
...organize.groups.map((group): Node => ({
|
||||
kind: "custom",
|
||||
group,
|
||||
tasks: groupMembers(tasks.values(), organize, group.id),
|
||||
})),
|
||||
...organize.groups.flatMap((group): Node[] => {
|
||||
const members = groupMembers(all, organize, group.id);
|
||||
return matches && !members.length ? [] : [{ kind: "custom", group, tasks: members }];
|
||||
}),
|
||||
...groupTasks(ungroupedMembers(all, organize)).map((group): Node => ({ kind: "group", group })),
|
||||
];
|
||||
}, [tasks, organize]);
|
||||
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
|
||||
}, [tasks, organize, matches]);
|
||||
const expanded = useMemo(
|
||||
() => new Set(roots.map(nodeKey).filter((k) => matches || !collapsed.has(k))),
|
||||
[roots, collapsed, matches],
|
||||
);
|
||||
// Dropping a session editor tab anywhere on the tool window docks it.
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const [tabOver, setTabOver] = useState(false);
|
||||
@@ -251,8 +269,35 @@ 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) =>
|
||||
@@ -331,7 +376,11 @@ 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}>
|
||||
{n.task.title}
|
||||
{matches?.get(n.task.id)?.length ? (
|
||||
<HighlightedText text={n.task.title} indices={matches.get(n.task.id)!} />
|
||||
) : (
|
||||
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} />}
|
||||
@@ -352,12 +401,18 @@ export function AgentsView() {
|
||||
}
|
||||
}}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
</div>
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export function activate(): () => void {
|
||||
icon: "toolwindow-usages",
|
||||
anchor: "bottom",
|
||||
stripe: "onUse",
|
||||
sessionContent: true,
|
||||
order: 35,
|
||||
component: UsagesView,
|
||||
headerContent: UsagesTabs,
|
||||
|
||||
@@ -107,3 +107,29 @@ describe("routine grouping", () => {
|
||||
expect(tree.queryByText("functions")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expand All", () => {
|
||||
it("opens every loaded node and leaves unloaded ones closed", async () => {
|
||||
useDatabase.setState({
|
||||
sources: [
|
||||
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
|
||||
{ id: "lite", name: "SQLite", driver: "sqlite" },
|
||||
],
|
||||
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
|
||||
treeExpanded: new Set(),
|
||||
});
|
||||
render(<DatabaseView />);
|
||||
await act(() => executeAction("db.expandAll"));
|
||||
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
|
||||
"pg",
|
||||
"pg/public",
|
||||
"pg/public/routines",
|
||||
"pg/public/tables",
|
||||
]);
|
||||
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
|
||||
expect(tree.getByText("refresh_data")).toBeInTheDocument();
|
||||
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
|
||||
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
|
||||
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
useDatabase,
|
||||
type Loaded,
|
||||
} from "./store";
|
||||
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
|
||||
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
|
||||
import { newMenu, nodeMenu } from "./treeMenu";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -366,6 +366,34 @@ export function DatabaseView() {
|
||||
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
|
||||
};
|
||||
|
||||
const expandable = (n: Node) =>
|
||||
n.kind === "source" ||
|
||||
n.kind === "database" ||
|
||||
n.kind === "schema" ||
|
||||
n.kind === "folder" ||
|
||||
n.kind === "table" ||
|
||||
n.kind === "group";
|
||||
|
||||
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
|
||||
useEffect(() => {
|
||||
databaseTree.expandAll = () => {
|
||||
const next = new Set(useDatabase.getState().treeExpanded);
|
||||
const walk = (nodes: readonly Node[]) => {
|
||||
for (const n of nodes) {
|
||||
const kids = expandable(n) ? children(n) : undefined;
|
||||
if (!kids) continue;
|
||||
next.add(n.key);
|
||||
walk(kids);
|
||||
}
|
||||
};
|
||||
walk(roots);
|
||||
useDatabase.setState({ treeExpanded: next });
|
||||
};
|
||||
return () => {
|
||||
databaseTree.expandAll = null;
|
||||
};
|
||||
});
|
||||
|
||||
const label = (n: Node): string => {
|
||||
switch (n.kind) {
|
||||
case "source":
|
||||
@@ -509,14 +537,7 @@ export function DatabaseView() {
|
||||
getKey={(n) => n.key}
|
||||
getLabel={label}
|
||||
getChildren={children}
|
||||
isExpandable={(n) =>
|
||||
n.kind === "source" ||
|
||||
n.kind === "database" ||
|
||||
n.kind === "schema" ||
|
||||
n.kind === "folder" ||
|
||||
n.kind === "table" ||
|
||||
n.kind === "group"
|
||||
}
|
||||
isExpandable={expandable}
|
||||
loadChildren={load}
|
||||
getIcon={(n) => icon(n)}
|
||||
getDetail={detail}
|
||||
@@ -662,7 +683,12 @@ function DatabaseToolbar() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header controls (shown on hover, like IDEA's): Collapse All. */
|
||||
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
|
||||
export function DatabaseHeaderActions() {
|
||||
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
|
||||
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
@@ -392,6 +393,7 @@ export function Grid(props: GridProps) {
|
||||
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
|
||||
canSetNull: () => canEdit() && nullOk(norm(sel!)),
|
||||
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
|
||||
canFilter: () => !!props.onFilter && !!sel && !editing,
|
||||
toggle: () => {
|
||||
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
|
||||
},
|
||||
@@ -401,6 +403,12 @@ export function Grid(props: GridProps) {
|
||||
setDefault: () => {
|
||||
if (actions.canSetDefault()) setDefaults(norm(sel!));
|
||||
},
|
||||
filter: () => {
|
||||
if (!actions.canFilter()) return;
|
||||
scrollColumnIntoView(sel!.c1);
|
||||
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
|
||||
if (anchor) props.onFilter!(sel!.c1, anchor);
|
||||
},
|
||||
};
|
||||
Object.assign((cellActions.current ??= {} as CellActions), actions);
|
||||
});
|
||||
@@ -430,6 +438,14 @@ export function Grid(props: GridProps) {
|
||||
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
|
||||
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
moveTo(next[0], next[1], false);
|
||||
return;
|
||||
}
|
||||
const moves: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
@@ -439,13 +455,32 @@ export function Grid(props: GridProps) {
|
||||
const m = moves[e.key];
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
|
||||
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
|
||||
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
|
||||
moveTo(
|
||||
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
|
||||
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
|
||||
e.shiftKey,
|
||||
);
|
||||
};
|
||||
|
||||
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
|
||||
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
|
||||
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
|
||||
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
|
||||
};
|
||||
|
||||
const moveTo = (r: number, c: number, extend: boolean) => {
|
||||
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
|
||||
virtualizer.scrollToIndex(r, { align: "auto" });
|
||||
scrollColumnIntoView(c);
|
||||
};
|
||||
|
||||
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
|
||||
const pointerFocus = useRef(false);
|
||||
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
|
||||
if (rowCount && columns.length) moveTo(0, 0, false);
|
||||
};
|
||||
|
||||
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
|
||||
if (e.button !== 0) {
|
||||
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
|
||||
@@ -512,6 +547,9 @@ export function Grid(props: GridProps) {
|
||||
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
|
||||
);
|
||||
}
|
||||
if (props.onFilter) {
|
||||
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
|
||||
}
|
||||
const extra = rowMenu?.(selectedRows()) ?? [];
|
||||
if (extra.length) items.push({ kind: "separator" }, ...extra);
|
||||
showContextMenu(e, items);
|
||||
@@ -546,6 +584,11 @@ export function Grid(props: GridProps) {
|
||||
aria-label={props["aria-label"] ?? "Results"}
|
||||
aria-rowcount={rowCount}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onMouseDownCapture={() => {
|
||||
pointerFocus.current = true;
|
||||
setTimeout(() => (pointerFocus.current = false));
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -615,6 +658,10 @@ export function Grid(props: GridProps) {
|
||||
{props.onFilter && (
|
||||
<button
|
||||
type="button"
|
||||
data-column-filter=""
|
||||
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
|
||||
// The keyboard opens it with the Local Filter action.
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
|
||||
aria-label={`Filter ${c.name}`}
|
||||
title={tx("Local filter")}
|
||||
@@ -709,6 +756,13 @@ export function Grid(props: GridProps) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Tab") {
|
||||
// Like IDEA's table editor: Tab commits and selects the next cell.
|
||||
e.preventDefault();
|
||||
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
|
||||
commit();
|
||||
if (next) moveTo(next[0], next[1], false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
skipBlurCommit.current = true;
|
||||
setEditing(null);
|
||||
@@ -726,6 +780,7 @@ export function Grid(props: GridProps) {
|
||||
<div
|
||||
key={c}
|
||||
role="gridcell"
|
||||
aria-selected={selected(r, c)}
|
||||
className={clsx(
|
||||
styles.cell,
|
||||
numeric[c] && styles.cellNumber,
|
||||
@@ -751,6 +806,7 @@ export function Grid(props: GridProps) {
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
tabIndex={-1}
|
||||
className={styles.cellCheckbox}
|
||||
aria-label={tx`Toggle ${columns[c]!.name}`}
|
||||
checked={val === lit.t}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
showContextMenu,
|
||||
T,
|
||||
tooltipAttrs,
|
||||
Toolbar,
|
||||
} from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
|
||||
@@ -80,10 +81,10 @@ function Output() {
|
||||
}, [lines.length]);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<Toolbar className={styles.toolbar} aria-label="Output actions">
|
||||
<span className={styles.spacer} />
|
||||
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
|
||||
</div>
|
||||
</Toolbar>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.output}
|
||||
@@ -148,12 +149,7 @@ function Pager({ tab }: { tab: ResultTab }) {
|
||||
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
|
||||
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
|
||||
return (
|
||||
<div
|
||||
className={styles.resultPager}
|
||||
role="toolbar"
|
||||
aria-label={tx("Result pages")}
|
||||
title={outcomeSummary(tab.outcome)}
|
||||
>
|
||||
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
|
||||
<IconButton
|
||||
icon="page-first"
|
||||
title="First Page"
|
||||
@@ -198,7 +194,7 @@ function Pager({ tab }: { tab: ResultTab }) {
|
||||
onChange={(v) => go(0, Number(v))}
|
||||
disabled={!canPage}
|
||||
/>
|
||||
</div>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +429,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
}
|
||||
return (
|
||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||
<div className={styles.toolbar}>
|
||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
||||
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
||||
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||
<span className={styles.toolbarSeparator} />
|
||||
@@ -487,7 +483,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
|
||||
/>
|
||||
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
||||
</div>
|
||||
</Toolbar>
|
||||
{view === "chart" ? (
|
||||
<div className={styles.fill}>
|
||||
<ChartView columns={columns} rows={r.rows} />
|
||||
@@ -595,7 +591,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
|
||||
const selectedKey = selected < 0 ? statement.key : String(selected);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
||||
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
<PinButton tab={tab} />
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
@@ -603,7 +599,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
{targetLabel(tab.sourceId)}
|
||||
</span>
|
||||
</div>
|
||||
</Toolbar>
|
||||
<div className={clsx(styles.fill, styles.planView)}>
|
||||
<div className={styles.planTree}>
|
||||
<div className={styles.planHeader}>
|
||||
@@ -677,6 +673,13 @@ export function ResultsView() {
|
||||
const active = useDatabase((s) => s.activeResult);
|
||||
const hasOutput = useDatabase((s) => s.output.length > 0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
|
||||
const focusContent = () => {
|
||||
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
|
||||
target?.focus();
|
||||
return !!target && document.activeElement === target;
|
||||
};
|
||||
useEffect(() => {
|
||||
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
|
||||
document.addEventListener("focusin", syncFocus);
|
||||
@@ -704,6 +707,7 @@ export function ResultsView() {
|
||||
<div
|
||||
ref={root}
|
||||
className={styles.view}
|
||||
data-focus-scope=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
|
||||
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
|
||||
@@ -724,6 +728,7 @@ export function ResultsView() {
|
||||
aria-label="Results"
|
||||
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
|
||||
panelIdPrefix="db-results"
|
||||
focusContent={focusContent}
|
||||
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
|
||||
onClose={(id) => closeResult(Number(id))}
|
||||
onTogglePin={(id) => {
|
||||
@@ -744,7 +749,13 @@ export function ResultsView() {
|
||||
]}
|
||||
/>
|
||||
</ToolWindowContentTabs>
|
||||
<div className={styles.fill}>
|
||||
<div
|
||||
ref={panel}
|
||||
className={styles.fill}
|
||||
role="tabpanel"
|
||||
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
|
||||
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
|
||||
>
|
||||
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,12 @@ export interface CellActions {
|
||||
canToggle(): boolean;
|
||||
canSetNull(): boolean;
|
||||
canSetDefault(): boolean;
|
||||
canFilter(): boolean;
|
||||
toggle(): void;
|
||||
setNull(): void;
|
||||
setDefault(): void;
|
||||
/** Opens the local filter of the selected cell's column. */
|
||||
filter(): void;
|
||||
}
|
||||
|
||||
const grids = new WeakMap<Element, CellActions>();
|
||||
@@ -59,6 +62,14 @@ export function installCellActions(): () => void {
|
||||
enabled: (c) => handle(c)?.canSetDefault() ?? false,
|
||||
run: (c) => handle(c)?.setDefault(),
|
||||
},
|
||||
{
|
||||
id: "db.localFilter",
|
||||
title: "Local Filter",
|
||||
category: "Database",
|
||||
keyWhen: "dbCellGridFocus",
|
||||
enabled: (c) => handle(c)?.canFilter() ?? false,
|
||||
run: (c) => handle(c)?.filter(),
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
dispose();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
@@ -432,6 +433,75 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
|
||||
const selectedCells = () =>
|
||||
screen
|
||||
.getAllByRole("gridcell")
|
||||
.filter((cell) => cell.getAttribute("aria-selected") === "true")
|
||||
.map((cell) => cell.textContent);
|
||||
const rows = [
|
||||
["1", "Ann"],
|
||||
["2", "Bob"],
|
||||
];
|
||||
|
||||
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
await screen.findByRole("button", { name: "Add Row" });
|
||||
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
|
||||
await user.tab();
|
||||
expect(screen.getByRole("grid")).toHaveFocus();
|
||||
expect(selectedCells()).toEqual(["1"]);
|
||||
await user.tab();
|
||||
expect(selectedCells()).toEqual(["Ann"]);
|
||||
await user.tab();
|
||||
expect(selectedCells()).toEqual(["2"]);
|
||||
await user.tab({ shift: true });
|
||||
await user.tab({ shift: true });
|
||||
expect(selectedCells()).toEqual(["1"]);
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
|
||||
document.activeElement as HTMLElement,
|
||||
);
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("leaves the grid after the last cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
|
||||
await user.tab();
|
||||
expect(screen.getByRole("grid")).not.toHaveFocus();
|
||||
expect(selectedCells()).toEqual(["Bob"]);
|
||||
});
|
||||
|
||||
it("commits the cell editor with Tab and selects the next cell", async () => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Cara" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
|
||||
expect(selectedCells()).toEqual(["2"]);
|
||||
expect(screen.getByRole("grid")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("goes from the Output tab to the log, past its toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
|
||||
act(() => screen.getByRole("tab", { name: "Output" }).focus());
|
||||
await user.tab();
|
||||
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
|
||||
@@ -44,6 +44,10 @@ import { tx } from "@/platform/i18n";
|
||||
|
||||
/** Context key: the Database tree has focus. */
|
||||
export const DB_VIEW_FOCUS = "databaseViewFocus";
|
||||
|
||||
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
|
||||
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
|
||||
|
||||
const OPEN = "projectOpen";
|
||||
|
||||
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
|
||||
@@ -481,6 +485,12 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
title: "Separate Procedures and Functions",
|
||||
run: toggleSeparateProceduresAndFunctions,
|
||||
}),
|
||||
tree({
|
||||
id: "db.expandAll",
|
||||
title: "Expand All",
|
||||
icon: "expand-all",
|
||||
run: () => databaseTree.expandAll?.(),
|
||||
}),
|
||||
tree({
|
||||
id: "db.collapseAll",
|
||||
title: "Collapse All",
|
||||
|
||||
@@ -19,7 +19,14 @@ 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", go: "delve", native: "codelldb" }[p.kind],
|
||||
adapter: {
|
||||
java: "java-debug",
|
||||
node: "js-debug",
|
||||
python: "debugpy",
|
||||
dotnet: "netcoredbg",
|
||||
go: "delve",
|
||||
native: "codelldb",
|
||||
}[p.kind],
|
||||
host: null,
|
||||
port: null,
|
||||
...p,
|
||||
@@ -38,6 +45,7 @@ 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", () => {
|
||||
@@ -47,6 +55,7 @@ describe("Attach to Process popup model", () => {
|
||||
"Java (JDWP):1",
|
||||
"Node.js:1",
|
||||
"Python (debugpy):2",
|
||||
".NET (netcoredbg):1",
|
||||
"Go (Delve):1",
|
||||
"Native (LLDB):1",
|
||||
]);
|
||||
@@ -83,6 +92,9 @@ describe("Attach to Process popup model", () => {
|
||||
address: "localhost",
|
||||
});
|
||||
expect(await extra(list[5]!)).toEqual({ mode: "local", processId: 26 });
|
||||
const dotnet = await attachConfig(list[6]!, "/proj");
|
||||
expect(dotnet).toMatchObject({ adapter: "netcoredbg", request: "attach" });
|
||||
expect(JSON.parse(dotnet.extraJson!)).toMatchObject({ processId: 40 });
|
||||
const native = await attachConfig(list[0]!, "/proj");
|
||||
expect(native).toMatchObject({ adapter: "codelldb", request: "attach", name: "a.out (30)", cwd: "/proj" });
|
||||
expect(JSON.parse(native.extraJson!)).toEqual({ pid: 30 });
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||
adapterData.library = "com.example.library.Library@1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
const value = await screen.findByText("com.example.library.Library@1");
|
||||
const value = await screen.findByText("{Library@1}");
|
||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||
});
|
||||
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
await screen.findByText("Collecting data…");
|
||||
adapterData.library = "Library@2";
|
||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||
expect(await screen.findByText("{Library@2}")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for an invalidation while the program runs", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { selectionExpression } from "../editor/valueTooltip";
|
||||
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
|
||||
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
|
||||
import { inlineValuesFor } from "../editor/execution";
|
||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
@@ -150,3 +150,25 @@ describe("selectionExpression", () => {
|
||||
expect(selectionExpression(multi)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shown values", () => {
|
||||
it.each([
|
||||
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
|
||||
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
|
||||
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
|
||||
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
|
||||
["Db@21", "Db", { label: "{Db@21}", text: "" }],
|
||||
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
|
||||
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
|
||||
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
|
||||
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
|
||||
['"hello"', "String", { label: null, text: '"hello"' }],
|
||||
["42", "int", { label: "{int}", text: "42" }],
|
||||
// Other debuggers' values keep their text and type.
|
||||
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
|
||||
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
|
||||
["0x1000", null, { label: null, text: "0x1000" }],
|
||||
])("%s {%s}", (value, type, shown) => {
|
||||
expect(shownValue(value, type)).toEqual(shown);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,13 @@ 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 } from "./java";
|
||||
import {
|
||||
isJavaAttachTarget,
|
||||
isJavaDebuggable,
|
||||
javaAttachConfig,
|
||||
javaLaunchConfig,
|
||||
jvmAttachDebuggerReady,
|
||||
} from "./java";
|
||||
import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap";
|
||||
import { refreshMemory, useMemory } from "./memory/memoryState";
|
||||
import { closeInstances, instancesOutdated, showInstances, showReferrers, useInstances } from "./memory/instancesState";
|
||||
@@ -478,6 +484,20 @@ 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",
|
||||
|
||||
@@ -10,11 +10,12 @@ 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", "go", "native"];
|
||||
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "dotnet", "go", "native"];
|
||||
|
||||
export type AttachRow =
|
||||
| { kind: "group"; key: string; label: string; count: number }
|
||||
@@ -84,6 +85,8 @@ 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":
|
||||
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { activeSession, useExecutionPoint } from "../state";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifiersIn, inlineValue } from "../values";
|
||||
import { identifiersIn, inlineValue, shownValue } from "../values";
|
||||
import { valueHint } from "./valueTooltip";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
@@ -113,11 +113,13 @@ export function inlineValuesFor(
|
||||
evaluateName: id,
|
||||
})
|
||||
: raw;
|
||||
const shown = shownValue(variable.value, variable.typeName);
|
||||
const value = shown.text || shown.label || variable.value;
|
||||
parts.push({
|
||||
name: id,
|
||||
text: `${id}: ${inlineValue(variable.value, 40)}`,
|
||||
text: `${id}: ${inlineValue(value, 40)}`,
|
||||
variable,
|
||||
truncated: variable.value.length > 40,
|
||||
truncated: value.length > 40,
|
||||
});
|
||||
if (parts.length >= MAX_PER_LINE) break;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { activeSession, evaluate } from "../state";
|
||||
import { inlineValue } from "../values";
|
||||
import { inlineValue, shownValue } from "../values";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup, selectableText } from "@/ui";
|
||||
import { mountValueTree } from "../ui/ValueHintTree";
|
||||
@@ -70,13 +70,19 @@ export function valueHint(
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.valueName!;
|
||||
name.textContent = `${expression} = `;
|
||||
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
|
||||
if (value.typeName) {
|
||||
const type = document.createElement("span");
|
||||
type.className = styles.valueType!;
|
||||
type.textContent = ` {${value.typeName}}`;
|
||||
dom.append(type);
|
||||
}
|
||||
const shown = shownValue(value.value, value.typeName);
|
||||
// A JVM object's `{Type@id}` leads its value; other types follow it.
|
||||
const leading = shown.label?.includes("@") ?? false;
|
||||
const type = (text: string) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = styles.valueType!;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
};
|
||||
dom.append(name);
|
||||
if (leading) dom.append(type(`${shown.label} `));
|
||||
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
|
||||
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
|
||||
return { dom, destroy: () => tree?.unmount() };
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ import { forgetMemory } from "./memory/memoryState";
|
||||
const useAnyOverlay = () =>
|
||||
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
|
||||
|
||||
/** The Debug stripe button's badge, like Run's: how many debug sessions (child sessions aside) are live. */
|
||||
function useLiveSessionCount(): number | null {
|
||||
return useDebug((s) => Object.values(s.sessions).filter((x) => x.parent === null && isLive(x)).length || null);
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
useDebugRunSessions.setState({
|
||||
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
||||
@@ -68,9 +73,11 @@ export function activate(): () => void {
|
||||
icon: "toolwindow-debug",
|
||||
anchor: "bottom",
|
||||
stripe: "onUse",
|
||||
sessionContent: true,
|
||||
order: 20,
|
||||
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
||||
useBadge: useLiveSessionCount,
|
||||
}),
|
||||
registerEditorKind(heapEditorKind),
|
||||
installHeapContext(),
|
||||
|
||||
@@ -80,6 +80,29 @@ 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)) {
|
||||
|
||||
@@ -176,30 +176,7 @@ export function DebugToolWindow() {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
|
||||
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
...(hasMemory ? [["memory", "Memory"] as const] : []),
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
|
||||
>
|
||||
{tx(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
|
||||
<span className={styles.sep} aria-hidden />
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
|
||||
<ToolButton action="debug.rerun" icon="rerun" />
|
||||
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
@@ -233,6 +210,29 @@ export function DebugToolWindow() {
|
||||
onClick={() => setMuted(!muted)}
|
||||
/>
|
||||
<MoreActions />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
...(hasMemory ? [["memory", "Memory"] as const] : []),
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
|
||||
>
|
||||
{tx(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||
{tab === "variables" ? (
|
||||
@@ -251,7 +251,12 @@ export function DebugToolWindow() {
|
||||
|
||||
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
id="debug-threads-variables"
|
||||
storageKey="openidea.debug.threadsVariables"
|
||||
className={styles.split}
|
||||
>
|
||||
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
||||
<FramesPane session={session} />
|
||||
</SplitPanel>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
|
||||
import { valueText } from "../valueText";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./VariablesView.module.css";
|
||||
@@ -486,20 +486,25 @@ function VarRow({
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
const shown = shownValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{!shown.entry && (
|
||||
<>
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
</>
|
||||
)}
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{oneLine(node.error)}</span>
|
||||
) : (
|
||||
<>
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
{shown.label && <span className={styles.type}>{shown.label}</span>}
|
||||
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
||||
{oneLine(node.value)}
|
||||
{oneLine(shown.text)}
|
||||
</span>
|
||||
{overflow && (
|
||||
<button
|
||||
|
||||
@@ -69,3 +69,35 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
|
||||
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
||||
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
|
||||
export interface ShownValue {
|
||||
label: string | null;
|
||||
text: string;
|
||||
/** A map entry: the row shows `key -> value` alone, without its index. */
|
||||
entry?: true;
|
||||
}
|
||||
|
||||
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
|
||||
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
|
||||
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
|
||||
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
|
||||
|
||||
/**
|
||||
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
|
||||
* a type. Values in other forms keep the adapter's text under `{type}`.
|
||||
*/
|
||||
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
|
||||
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
|
||||
if (!typeName) return plain;
|
||||
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
|
||||
const object = JVM_OBJECT.exec(value);
|
||||
if (!object) return plain;
|
||||
const [, head, id, rest = ""] = object;
|
||||
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
|
||||
if (simple(head!) !== simple(typeName)) return plain;
|
||||
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
|
||||
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
|
||||
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
|
||||
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SyncEntry } from "@/platform/ipc";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
||||
servers: [] as DeploymentServerValid[],
|
||||
groups: [] as { id: string; name: string; servers: string[] }[],
|
||||
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
||||
choose: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
@@ -37,7 +38,7 @@ vi.mock("@/platform/project", () => ({
|
||||
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
||||
}));
|
||||
vi.mock("@/platform/workflowSettings", () => ({
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
@@ -72,6 +73,7 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
||||
mocks.groups = [];
|
||||
mocks.coverage = [
|
||||
{
|
||||
server: "one",
|
||||
@@ -91,6 +93,34 @@ beforeEach(() => {
|
||||
useDeployment.setState({ sync: null });
|
||||
registerDeploymentActions();
|
||||
});
|
||||
describe("server groups", () => {
|
||||
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
|
||||
mocks.groups = [
|
||||
{ id: "g", name: "Production", servers: ["one", "two"] },
|
||||
{ id: "none", name: "Elsewhere", servers: ["two"] },
|
||||
];
|
||||
mocks.coverage[1]!.paths = [];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
|
||||
await run("deployment.uploadTo");
|
||||
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
|
||||
});
|
||||
it("uploads to every covered server of the group in order", async () => {
|
||||
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
|
||||
mocks.coverage[1]!.paths = ["/project/b"];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
await run("deployment.uploadTo");
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
|
||||
});
|
||||
});
|
||||
describe("deployment eligibility", () => {
|
||||
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
||||
mocks.servers = [mocks.servers[0]!];
|
||||
|
||||
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
|
||||
: "No deployment server covers the selected paths",
|
||||
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
|
||||
group: GROUP,
|
||||
key: "deployment.noCoverage",
|
||||
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
|
||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
/** The servers covering `paths`, or null (after saying why) when none does. */
|
||||
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const configured = deployment().servers;
|
||||
if (!configured.length) {
|
||||
notifyNoServers();
|
||||
@@ -119,11 +119,36 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const labels = servers.map(serverLabel);
|
||||
const picked = await chooseItem({ title, items: labels });
|
||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||
}
|
||||
|
||||
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
|
||||
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const { servers: configured, groups } = deployment();
|
||||
const choices = new Map<string, DeploymentServerValid[]>();
|
||||
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
|
||||
for (const group of groups) {
|
||||
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
|
||||
if (!members.some((m) => servers.includes(m))) continue;
|
||||
const name = group.name.trim() || group.id;
|
||||
const label = tx`${name} (group)`;
|
||||
if (!choices.has(label)) choices.set(label, members);
|
||||
}
|
||||
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
|
||||
return picked === null ? null : (choices.get(picked) ?? null);
|
||||
}
|
||||
|
||||
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
||||
}
|
||||
@@ -160,7 +185,8 @@ export function showReport(
|
||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||
group: GROUP,
|
||||
key: `deployment.${verb}`,
|
||||
// One per server, so each server of a group reports on its own.
|
||||
key: `deployment.${verb}.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +197,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
||||
title: title.replace("{server}", serverLabel(server)),
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
key: "deployment.failure",
|
||||
key: `deployment.failure.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,7 +477,10 @@ export function registerDeploymentActions(): () => void {
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
|
||||
run: withTargets(async (paths) => {
|
||||
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
|
||||
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadRemoteInto",
|
||||
|
||||
@@ -55,6 +55,12 @@ export const UI_KEYS: Record<string, string[]> = {
|
||||
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
||||
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
|
||||
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
|
||||
// The selected tab of a part without focus (src/ui/focusScope.ts).
|
||||
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
|
||||
"--border-tab-selected-inactive": [
|
||||
"EditorTabs.inactiveUnderlinedTabBorderColor",
|
||||
"EditorTabs.inactiveUnderlineColor",
|
||||
],
|
||||
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
|
||||
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
||||
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
||||
|
||||
@@ -301,6 +301,32 @@ it("offers workspace removal for every member root", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
root: "/work/workspaces/demo",
|
||||
name: "demo",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
|
||||
},
|
||||
});
|
||||
setContext("vcsAvailable", true);
|
||||
try {
|
||||
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
|
||||
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
|
||||
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
|
||||
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
|
||||
// Project structure is the workspace folder's: its roots can't name a folder outside it.
|
||||
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
|
||||
// Scratches stay outside.
|
||||
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
|
||||
} finally {
|
||||
clearContext("vcsAvailable");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not offer removal on the attached primary", () => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
|
||||
@@ -151,6 +151,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
const project = useProject.getState().project;
|
||||
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
|
||||
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
|
||||
// Workspace members may live outside the workspace folder; they have VCS and usages too.
|
||||
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
|
||||
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
|
||||
const items: MenuItem[] = [];
|
||||
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
|
||||
|
||||
@@ -228,7 +231,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
shortcut: keyLabel("project.open"),
|
||||
run: () => files.forEach((f) => void openFile(f.path)),
|
||||
},
|
||||
inProject &&
|
||||
inWorkspace &&
|
||||
singleFile && {
|
||||
id: "findUsages",
|
||||
label: "Find Usages",
|
||||
@@ -338,7 +341,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
);
|
||||
|
||||
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
|
||||
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
|
||||
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
|
||||
const arg = vcsArg(nodes);
|
||||
const dirs = nodes.some((n) => n.dir);
|
||||
push(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
initProjectTree,
|
||||
leadSelection,
|
||||
reloadAll,
|
||||
reloadArchiveDirs,
|
||||
reloadLoadedDirs,
|
||||
setChangedFiles,
|
||||
setOpenFiles,
|
||||
@@ -253,6 +254,7 @@ 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());
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
import { visibleInfo } from "./model";
|
||||
import {
|
||||
applyFsChange,
|
||||
reloadArchiveDirs,
|
||||
reloadLoadedDirs,
|
||||
currentModel,
|
||||
expandAll,
|
||||
@@ -114,6 +115,28 @@ 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");
|
||||
|
||||
@@ -565,6 +565,16 @@ 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();
|
||||
|
||||
@@ -381,6 +381,7 @@ 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)),
|
||||
|
||||
@@ -4,6 +4,8 @@ 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")>();
|
||||
@@ -15,8 +17,12 @@ 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 }) => {
|
||||
executeAction: async (id: string, arg: { port: number; retry: () => void; done: (ready: boolean) => void }) => {
|
||||
if (id === "debug.attach") h.attached.push(arg.port);
|
||||
if (id === "debug.jvmAttachReady") {
|
||||
h.retry = arg.retry;
|
||||
arg.done(h.ready);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
@@ -44,6 +50,8 @@ const mavenTest: RunConfig = {
|
||||
beforeEach(() => {
|
||||
h.attached = [];
|
||||
h.onLine = null;
|
||||
h.ready = true;
|
||||
h.retry = null;
|
||||
});
|
||||
|
||||
describe("Maven test debugging", () => {
|
||||
@@ -66,4 +74,13 @@ 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());
|
||||
});
|
||||
});
|
||||
@@ -150,11 +150,31 @@ 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[] = [];
|
||||
|
||||
@@ -185,6 +185,7 @@ 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)),
|
||||
|
||||
@@ -43,13 +43,23 @@ export function AddInterpreterDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ids = { base: useId(), location: useId(), existing: useId() };
|
||||
|
||||
const baseValue = base ?? bases[0]?.path ?? null;
|
||||
const toOption = (s: { path: string; name: string }): SelectOption => ({
|
||||
value: s.path,
|
||||
label: s.name,
|
||||
detail: tildify(s.path),
|
||||
});
|
||||
const baseOptions = bases.map(toOption);
|
||||
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
|
||||
const versionOptions = (() => {
|
||||
const seen = new Map<string, SelectOption>();
|
||||
for (const s of bases) {
|
||||
const short = s.version?.split(".").slice(0, 2).join(".");
|
||||
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
|
||||
})();
|
||||
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
|
||||
const baseValue =
|
||||
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
|
||||
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
|
||||
value: s.path,
|
||||
label: `${s.name} — ${tildify(s.path)}`,
|
||||
@@ -108,7 +118,9 @@ export function AddInterpreterDialog({
|
||||
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
|
||||
const note = {
|
||||
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
|
||||
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
|
||||
conda: tx(
|
||||
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
|
||||
),
|
||||
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
|
||||
poetry: tx(
|
||||
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
|
||||
@@ -143,6 +155,7 @@ export function AddInterpreterDialog({
|
||||
aria-current={kind === k.tool || undefined}
|
||||
onClick={() => {
|
||||
setKind(k.tool);
|
||||
setBase(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
@@ -199,9 +212,7 @@ export function AddInterpreterDialog({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor={ids.base}>
|
||||
<T>Base interpreter:</T>
|
||||
</label>
|
||||
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
|
||||
<div className={styles.pathRow}>
|
||||
<Select
|
||||
className={styles.pathInput}
|
||||
@@ -209,17 +220,19 @@ export function AddInterpreterDialog({
|
||||
options={baseOptions}
|
||||
onChange={setBase}
|
||||
placeholder={loading ? "Detecting…" : "No Python found"}
|
||||
aria-label="Base interpreter"
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
onClick={async () => {
|
||||
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
|
||||
if (p) setBase(p);
|
||||
}}
|
||||
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
|
||||
/>
|
||||
{kind !== "conda" && (
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
onClick={async () => {
|
||||
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
|
||||
if (p) setBase(p);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.note}>{note}</div>
|
||||
</>
|
||||
|
||||
@@ -68,6 +68,8 @@ export function PythonInterpreterPage() {
|
||||
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [auto, setAuto] = useState<string | null>(null);
|
||||
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
|
||||
const [created, setCreated] = useState(0);
|
||||
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
|
||||
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
|
||||
const extras = useMemo(
|
||||
@@ -91,7 +93,7 @@ export function PythonInterpreterPage() {
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [root]);
|
||||
}, [root, created]);
|
||||
|
||||
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
|
||||
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
|
||||
@@ -104,7 +106,7 @@ export function PythonInterpreterPage() {
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [root, interpreter]);
|
||||
}, [root, interpreter, created]);
|
||||
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
|
||||
const invalid =
|
||||
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
|
||||
@@ -362,6 +364,8 @@ export function PythonInterpreterPage() {
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={(path) => {
|
||||
setInterpreter(path);
|
||||
setCreated((n) => n + 1);
|
||||
reload();
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -194,3 +194,13 @@
|
||||
padding: 16px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.groupMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.groupTitle {
|
||||
color: var(--fg-subtle);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -13,10 +13,24 @@ import {
|
||||
deploymentConnectable,
|
||||
deploymentConnection,
|
||||
deploymentSecretKey,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentServerValid,
|
||||
type FtpConnectionValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
T,
|
||||
Tabs,
|
||||
} from "@/ui";
|
||||
import { GlobListEditor, Row } from "../components/Form";
|
||||
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
||||
import { useSetting } from "../components/context";
|
||||
@@ -25,10 +39,13 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
import { choosePath } from "../languages/api";
|
||||
@@ -611,44 +628,118 @@ function ExcludedPathsTab({
|
||||
|
||||
type ServerTab = "connection" | "mappings" | "excluded";
|
||||
|
||||
function GroupPanel({
|
||||
group,
|
||||
servers,
|
||||
update,
|
||||
}: {
|
||||
group: DeploymentGroupValid;
|
||||
servers: readonly DeploymentServerValid[];
|
||||
update: (patch: Partial<DeploymentGroupValid>) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
|
||||
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
|
||||
<div className={styles.groupTitle}>
|
||||
<T>Servers in group</T>
|
||||
</div>
|
||||
{servers.length === 0 && (
|
||||
<div className={styles.note}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
checked={group.servers.includes(s.id)}
|
||||
onChange={() => update(toggleGroupServer(group, s.id, servers))}
|
||||
label={<span>{s.name || s.id}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.note}>
|
||||
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Selection = { kind: "server" | "group"; id: string } | null;
|
||||
|
||||
function DeploymentServersEditor() {
|
||||
const tx = useTx();
|
||||
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
||||
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
|
||||
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
||||
const list = servers ?? [];
|
||||
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
|
||||
const groupList = groups ?? [];
|
||||
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
const [tab, setTab] = useState<ServerTab>("connection");
|
||||
const current = list.find((s) => s.id === selected) ?? null;
|
||||
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
|
||||
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
|
||||
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
||||
|
||||
const commit = (next: DeploymentServerValid[]) => {
|
||||
setServers(next);
|
||||
const d = fixDefaultServer(next, defaultServer ?? "");
|
||||
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
||||
const pruned = pruneGroups(groupList, next);
|
||||
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
|
||||
};
|
||||
const update = (patch: Partial<DeploymentServerValid>) => {
|
||||
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
|
||||
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
|
||||
};
|
||||
const add = (base?: DeploymentServerValid) => {
|
||||
const server = newServer(list, base);
|
||||
commit([...list, server]);
|
||||
setSelected(server.id);
|
||||
setSelected({ kind: "server", id: server.id });
|
||||
setTab("connection");
|
||||
};
|
||||
const addGroup = () => {
|
||||
const group = newGroup(groupList);
|
||||
setGroups([...groupList, group]);
|
||||
setSelected({ kind: "group", id: group.id });
|
||||
};
|
||||
const remove = () => {
|
||||
if (currentGroup) {
|
||||
const index = groupList.indexOf(currentGroup);
|
||||
const rest = groupList.filter((g) => g.id !== currentGroup.id);
|
||||
setGroups(rest);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
return;
|
||||
}
|
||||
if (!current) return;
|
||||
const index = list.indexOf(current);
|
||||
const rest = list.filter((s) => s.id !== current.id);
|
||||
commit(rest);
|
||||
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "server", id: next.id } : null);
|
||||
};
|
||||
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div className={workflow.tableBox}>
|
||||
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
||||
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
||||
{ label: "Server", icon: "web", run: () => add() },
|
||||
{ label: "Server Group", icon: "folder", run: addGroup },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
|
||||
<IconButton
|
||||
icon="copy"
|
||||
title="Copy"
|
||||
@@ -665,18 +756,31 @@ function DeploymentServersEditor() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
||||
{list.length === 0 && (
|
||||
{list.length === 0 && groupList.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{groupList.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
role="option"
|
||||
aria-selected={isSelected("group", g.id)}
|
||||
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
|
||||
>
|
||||
<Icon name="folder" />
|
||||
<span className={styles.serverName}>{g.name || g.id}</span>
|
||||
<Badge>{g.servers.length}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{list.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
role="option"
|
||||
aria-selected={s.id === selected}
|
||||
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected(s.id)}
|
||||
aria-selected={isSelected("server", s.id)}
|
||||
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
|
||||
>
|
||||
<Icon name="web" />
|
||||
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
||||
@@ -687,7 +791,11 @@ function DeploymentServersEditor() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{current ? (
|
||||
{currentGroup ? (
|
||||
<div className={styles.detail}>
|
||||
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
|
||||
</div>
|
||||
) : current ? (
|
||||
<div className={styles.detail}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -780,6 +888,7 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
|
||||
"Passive mode",
|
||||
"Test Connection",
|
||||
"Use as Default",
|
||||
"Server Group",
|
||||
"Local path",
|
||||
"Deployment path",
|
||||
"Web path",
|
||||
|
||||
@@ -5,14 +5,40 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
nextServerName,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
|
||||
describe("server groups", () => {
|
||||
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
|
||||
it("names a new group after the first free number", () => {
|
||||
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
|
||||
expect(g.name).toBe("Group 3");
|
||||
expect(g.servers).toEqual([]);
|
||||
expect(g.id).toMatch(/^group-/);
|
||||
});
|
||||
it("toggles members, keeping the server list's order", () => {
|
||||
let g = { id: "g", name: "G", servers: [] as string[] };
|
||||
g = toggleGroupServer(g, "c", servers);
|
||||
g = toggleGroupServer(g, "a", servers);
|
||||
expect(g.servers).toEqual(["a", "c"]);
|
||||
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
|
||||
});
|
||||
it("drops removed servers from groups and keeps untouched groups as they are", () => {
|
||||
const keep = { id: "k", name: "K", servers: ["a"] };
|
||||
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
|
||||
expect(pruned[0]).toBe(keep);
|
||||
expect(pruned[1]!.servers).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exclude names", () => {
|
||||
it("parses a ;-separated list", () => {
|
||||
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
DEFAULT_DEPLOYMENT_SERVER,
|
||||
newRemoteId,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentMappingValid,
|
||||
type DeploymentServerValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
@@ -67,6 +68,37 @@ export function newServer(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new, empty group named "Group <n>" (the first free number). */
|
||||
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
|
||||
const names = new Set(groups.map((g) => g.name));
|
||||
let n = groups.length + 1;
|
||||
while (names.has(`Group ${n}`)) n++;
|
||||
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
|
||||
}
|
||||
|
||||
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
|
||||
export function toggleGroupServer(
|
||||
group: DeploymentGroupValid,
|
||||
server: string,
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid {
|
||||
const members = new Set(group.servers);
|
||||
if (members.has(server)) members.delete(server);
|
||||
else members.add(server);
|
||||
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
|
||||
}
|
||||
|
||||
/** `groups` without members that are no longer in `servers`. */
|
||||
export function pruneGroups(
|
||||
groups: readonly DeploymentGroupValid[],
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid[] {
|
||||
const ids = new Set(servers.map((s) => s.id));
|
||||
return groups.map((g) =>
|
||||
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
||||
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
||||
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride }
|
||||
import {
|
||||
ATTRIBUTES,
|
||||
SCHEMES,
|
||||
EFFECT_CSS,
|
||||
overrideDeclarations,
|
||||
effectiveScheme,
|
||||
resolvedSchemeTokens,
|
||||
@@ -123,6 +124,8 @@ function previewVars(
|
||||
k.startsWith("--border-") ||
|
||||
k.startsWith("--ansi-") ||
|
||||
k.startsWith("--git-") ||
|
||||
k.startsWith("--wave-") ||
|
||||
k.startsWith("--editor-") ||
|
||||
k === "--caret"
|
||||
)
|
||||
vars[k] = v;
|
||||
@@ -137,20 +140,32 @@ function previewVars(
|
||||
|
||||
/** Text styled like an attribute in the preview. */
|
||||
function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
|
||||
if (!attr?.token)
|
||||
return {
|
||||
const base = attr?.fg ?? attr?.effect;
|
||||
if (!attr?.token) {
|
||||
const colour: CSSProperties = {
|
||||
...(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>-*`, 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)`,
|
||||
}
|
||||
};
|
||||
// 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) }
|
||||
: null),
|
||||
};
|
||||
}
|
||||
if (attr.italicOnly)
|
||||
return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` };
|
||||
const t = attr.token;
|
||||
@@ -206,6 +221,11 @@ const SAMPLES: Record<string, CodeSample> = {
|
||||
[" ", "text"],
|
||||
["legacyCall", "unused"],
|
||||
],
|
||||
[
|
||||
["typo", "typo"],
|
||||
[" ", "text"],
|
||||
["deprecatedCall", "deprecated"],
|
||||
],
|
||||
[
|
||||
["bad\u203dchar", "invalid"],
|
||||
[" \u00b7 ", "whitespaces"],
|
||||
|
||||
@@ -1107,7 +1107,8 @@ export function DiffEditor({
|
||||
viewer: viewerOf,
|
||||
});
|
||||
useEffect(() => {
|
||||
nav.current = {
|
||||
// Keep the registered navigator's identity while refreshing its handlers.
|
||||
Object.assign(nav.current, {
|
||||
next: () => step(1),
|
||||
prev: () => step(-1),
|
||||
jumpToSource,
|
||||
@@ -1115,7 +1116,7 @@ export function DiffEditor({
|
||||
file,
|
||||
showSettings,
|
||||
viewer: viewerOf,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-app);
|
||||
outline: none;
|
||||
}
|
||||
.toolbar {
|
||||
flex: none;
|
||||
@@ -84,6 +85,7 @@
|
||||
}
|
||||
.message {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -541,6 +541,10 @@ 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";
|
||||
|
||||
@@ -560,6 +560,41 @@ describe("Jump to Source of a revision diff", () => {
|
||||
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
|
||||
});
|
||||
|
||||
it.each([{ key: "ArrowDown", metaKey: true }, { key: "F4" }])(
|
||||
"jumps with $key when the working copy is found after the pane gains focus",
|
||||
async (stroke) => {
|
||||
setUp();
|
||||
let finishProbe!: (result: unknown) => void;
|
||||
probe.mockReturnValue(new Promise((resolve) => (finishProbe = resolve)));
|
||||
const keymap = useKeymap.getState();
|
||||
applyKeymapSettings("intellij-macos", {});
|
||||
const unregister = registerCompareActions();
|
||||
const uninstall = installKeyboardDispatcher(window);
|
||||
const mounted = render(<DiffEditor input={input} />);
|
||||
try {
|
||||
const view = await pane(1);
|
||||
view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 1 } });
|
||||
view.focus();
|
||||
expect(view.contentDOM).toBe(document.activeElement);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
|
||||
|
||||
finishProbe({ status: "ok", data: { size: working.length, binary: false, tooLarge: false } });
|
||||
await screen.findByRole("button", { name: "Jump to Source" });
|
||||
expect(view.contentDOM).toBe(document.activeElement);
|
||||
fireEvent.keyDown(view.contentDOM, { ...stroke, code: stroke.key });
|
||||
await waitFor(() =>
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith("/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }),
|
||||
);
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
setContext("diffEditorFocus", false);
|
||||
uninstall();
|
||||
unregister();
|
||||
useKeymap.setState(keymap);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("jumps on ⌘↓ in the IntelliJ macOS keymap only while a diff pane has focus", async () => {
|
||||
setUp();
|
||||
const keymap = useKeymap.getState();
|
||||
|
||||
@@ -49,6 +49,7 @@ 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,
|
||||
@@ -246,6 +247,15 @@ 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…",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 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();
|
||||
}
|
||||
@@ -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). */
|
||||
async function discoverRoots() {
|
||||
export async function discoverRoots() {
|
||||
const root = useVcs.getState().root;
|
||||
if (!root || !isTauri()) return;
|
||||
const roots = await call(commands.vcsRoots()).catch(() => []);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ATTRIBUTES,
|
||||
CLEAR_COLOR,
|
||||
_setSchemeSource,
|
||||
overrideDeclarations,
|
||||
resolvedAttribute,
|
||||
schemeAfterThemeChange,
|
||||
schemeCss,
|
||||
@@ -341,4 +342,43 @@ 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 });
|
||||
});
|
||||
});
|
||||
+70
-17
@@ -31,7 +31,8 @@ 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). */
|
||||
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces,
|
||||
* Errors and Warnings): overrides land on `--<fg|effect>-*`. */
|
||||
fontStyle?: true;
|
||||
defaultBold?: boolean;
|
||||
defaultItalic?: boolean;
|
||||
@@ -139,10 +140,46 @@ 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" },
|
||||
{ id: "warning", name: "Warning", path: ["General", "Errors and Warnings"], effect: "--wave-warning" },
|
||||
{
|
||||
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: "weakWarning", name: "Weak warning", path: ["General", "Errors and Warnings"], fg: "--fg-weak-warning" },
|
||||
{ id: "unused", name: "Unused symbol", path: ["General", "Errors and Warnings"], fg: "--fg-unused" },
|
||||
{
|
||||
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,
|
||||
},
|
||||
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" },
|
||||
@@ -339,6 +376,11 @@ 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",
|
||||
@@ -477,7 +519,7 @@ export function boldWeight(bold: boolean): string {
|
||||
return bold ? "var(--fw-code-bold, 700)" : "var(--fw-code, 400)";
|
||||
}
|
||||
|
||||
const EFFECT_CSS: Record<string, (c: string) => string> = {
|
||||
export const EFFECT_CSS: Record<string, (c: string) => string> = {
|
||||
underline: (c) => `underline ${c}`,
|
||||
wave: (c) => `underline wavy ${c}`,
|
||||
strikeout: (c) => `line-through ${c}`,
|
||||
@@ -507,9 +549,12 @@ 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)) {
|
||||
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.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.token) {
|
||||
const t = attr.token;
|
||||
@@ -534,7 +579,9 @@ export function overrideDeclarations(
|
||||
if (o.effect)
|
||||
out.push([
|
||||
`${attr.effect ?? base}-effect`,
|
||||
EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none",
|
||||
EFFECT_CSS[o.effect]?.(
|
||||
setOrCleared(o.effectColor) ? o.effectColor! : attr.effect ? `var(${attr.effect})` : "currentColor",
|
||||
) ?? "none",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -646,14 +693,20 @@ 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;
|
||||
} 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;
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ describe("catalogs", () => {
|
||||
"my-agent",
|
||||
"Java",
|
||||
"Python",
|
||||
"Python {short}",
|
||||
"C/C++",
|
||||
"C#",
|
||||
"Go",
|
||||
|
||||
@@ -404,6 +404,7 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"db.runSqlScript": [],
|
||||
"db.sqlScript": [],
|
||||
"db.goToReferencedTable": [],
|
||||
"db.expandAll": [],
|
||||
"db.collapseAll": [],
|
||||
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
||||
"terminal.new": ["Meta+T"],
|
||||
|
||||
@@ -402,6 +402,7 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"db.runSqlScript": [],
|
||||
"db.sqlScript": [],
|
||||
"db.goToReferencedTable": [],
|
||||
"db.expandAll": [],
|
||||
"db.collapseAll": [],
|
||||
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
||||
"terminal.new": ["Ctrl+Shift+T"],
|
||||
|
||||
@@ -392,6 +392,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"db.runSqlScript": [],
|
||||
"db.sqlScript": [],
|
||||
"db.goToReferencedTable": [],
|
||||
"db.expandAll": [],
|
||||
"db.collapseAll": [],
|
||||
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
|
||||
"find.popup.nextResult": ["Meta+G"],
|
||||
|
||||
@@ -1011,6 +1011,7 @@
|
||||
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
|
||||
"Set DEFAULT": "Set DEFAULT",
|
||||
"Toggle Boolean Cell": "Toggle Boolean Cell",
|
||||
"Local Filter": "Local Filter",
|
||||
"Toggle {name}": "Toggle {name}",
|
||||
"true": "true",
|
||||
"false": "false",
|
||||
@@ -1505,5 +1506,20 @@
|
||||
"Port": "Port",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
||||
"Keep the token": "Keep the token",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
|
||||
"{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"
|
||||
}
|
||||
@@ -5217,7 +5217,9 @@
|
||||
"{count} modified action": "已修改 {count} 个操作",
|
||||
"{count} modified actions": "已修改 {count} 个操作",
|
||||
"Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.": "运行“<base> -m venv <location>”;新的解释器将成为项目解释器。",
|
||||
"Runs “conda create -p <location>”; the new interpreter becomes the project interpreter.": "运行“conda create -p <location>”;新的解释器将成为项目解释器。",
|
||||
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.": "运行“conda create -p <location> python=<version>”:新建一个只包含 Python 的环境;新的解释器将成为项目解释器。",
|
||||
"Python version": "Python 版本",
|
||||
"Python {short}": "Python {short}",
|
||||
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use <base>”;Poetry 会将环境保存在其自己的缓存目录下。",
|
||||
"Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter.": "运行“uv venv <location> --python <base>”;新的解释器将成为项目解释器。",
|
||||
"{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",
|
||||
@@ -6842,6 +6844,8 @@
|
||||
"Bad regex": "正则表达式无效",
|
||||
"Filter rows": "筛选行",
|
||||
"Result pages": "结果分页",
|
||||
"Result actions": "结果操作",
|
||||
"Output actions": "输出操作",
|
||||
"First Page": "第一页",
|
||||
"Count rows": "统计行数",
|
||||
"Last Page": "最后一页",
|
||||
@@ -7372,6 +7376,7 @@
|
||||
"Sources not found": "未找到源代码",
|
||||
"Cannot load library sources": "无法加载库源代码",
|
||||
"Toggle Boolean Cell": "切换布尔单元格",
|
||||
"Local Filter": "本地筛选",
|
||||
"Toggle {name}": "切换 {name}",
|
||||
"true": "真",
|
||||
"false": "假",
|
||||
@@ -7902,5 +7907,20 @@
|
||||
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
|
||||
"Keep the token": "保留令牌",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
|
||||
"{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": "没有匹配的任务"
|
||||
}
|
||||
@@ -43,8 +43,17 @@ export interface DeploymentServerValid {
|
||||
excludedPaths: string[];
|
||||
}
|
||||
|
||||
/** Servers that Upload to… on the group uploads to, one after another. */
|
||||
export interface DeploymentGroupValid {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Server ids, in upload order. */
|
||||
servers: string[];
|
||||
}
|
||||
|
||||
export interface DeploymentSettingsValid {
|
||||
servers: DeploymentServerValid[];
|
||||
groups: DeploymentGroupValid[];
|
||||
defaultServer: string;
|
||||
autoUpload: AutoUploadValid;
|
||||
deleteRemote: boolean;
|
||||
@@ -106,6 +115,7 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
|
||||
|
||||
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
||||
servers: [],
|
||||
groups: [],
|
||||
defaultServer: "",
|
||||
autoUpload: "never",
|
||||
deleteRemote: false,
|
||||
@@ -206,8 +216,14 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
|
||||
mappings: validateMappings(s.mappings),
|
||||
excludedPaths: strings(s.excludedPaths, []),
|
||||
}));
|
||||
const groups = withIds(o.groups, "group", (g) => ({
|
||||
id: text(g.id, ""),
|
||||
name: text(g.name, ""),
|
||||
servers: strings(g.servers, []),
|
||||
}));
|
||||
return {
|
||||
servers,
|
||||
groups,
|
||||
defaultServer: text(o.defaultServer, d.defaultServer),
|
||||
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
|
||||
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
|
||||
|
||||
@@ -73,6 +73,11 @@ 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);
|
||||
|
||||
@@ -204,6 +204,8 @@ 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;
|
||||
@@ -643,6 +645,8 @@ 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;
|
||||
@@ -935,6 +939,8 @@ 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;
|
||||
@@ -1147,6 +1153,8 @@ 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);
|
||||
|
||||
+13
-1
@@ -12,10 +12,21 @@ export interface CheckboxProps {
|
||||
/** Tri-state display. */
|
||||
indeterminate?: boolean;
|
||||
className?: string;
|
||||
/** `-1` keeps it out of the Tab order, e.g. inside a grid that moves between cells itself. */
|
||||
tabIndex?: number;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
indeterminate,
|
||||
className,
|
||||
tabIndex,
|
||||
...aria
|
||||
}: CheckboxProps) {
|
||||
const id = useId();
|
||||
const tx = useGivenTx();
|
||||
return (
|
||||
@@ -27,6 +38,7 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl
|
||||
className={styles.native}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
aria-label={tx(aria["aria-label"])}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
|
||||
@@ -344,6 +344,9 @@ 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 });
|
||||
}}
|
||||
|
||||
@@ -283,6 +283,17 @@ 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 });
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user