mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f00a29f9 | ||
|
|
834f9e172c | ||
|
|
45badba915 | ||
|
|
71728a473a | ||
|
|
2acd5cf479 | ||
|
|
85f4ffb67c | ||
|
|
4348dab26e | ||
|
|
38d4dd8c16 | ||
|
|
58af3c55a5 | ||
|
|
a8643366c8 | ||
|
|
22685815cc | ||
|
|
e6b88b2701 | ||
|
|
c076dedefc | ||
|
|
3764d963d7 | ||
|
|
4d2bcbbc26 | ||
|
|
56d9c8908c | ||
|
|
0bd0c4e03b | ||
|
|
209fc796ce | ||
|
|
e7e89e46c2 | ||
|
|
c87ab87417 | ||
|
|
a9ad1f1bbb | ||
|
|
4c8caca6a5 | ||
|
|
2ec2e75d40 | ||
|
|
04d5d9bd58 | ||
|
|
9a415e6875 | ||
|
|
6f83f8cad0 | ||
|
|
b971c8b391 | ||
|
|
c333990404 | ||
|
|
70b953c77a | ||
|
|
cb18b88531 | ||
|
|
401d7a4105 | ||
|
|
46bd1f6fb8 | ||
|
|
85f755591b | ||
|
|
06b7513210 | ||
|
|
09aa03bb86 | ||
|
|
317988cfa7 | ||
|
|
7db2e98269 |
No files matched your search
@@ -78,7 +78,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
with: { key: "${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
Generated
+1
@@ -4288,6 +4288,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
The IDE you know, rebuilt in Rust.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
@@ -19,8 +23,6 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<a href="#sponsor">Sponsor</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -70,17 +72,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Sponsor
|
||||
|
||||
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
|
||||
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
你熟悉的 IDE,用 Rust 重新打造。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<a href="#赞助">赞助</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
|
||||
为核心的原生桌面应用里。
|
||||
|
||||
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
|
||||
提供 macOS、Windows 和 Linux 版本。
|
||||
|
||||
## 性能对比
|
||||
|
||||
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
|
||||
取 5 次运行的中位数:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| --------------------- | -------------- | ---------------------- |
|
||||
| 显示项目窗口 | **0.9 s** | 3.1 s |
|
||||
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
|
||||
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
|
||||
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
|
||||
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
|
||||
| 退出 | **0.3 s** | 5.9 s |
|
||||
|
||||
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
|
||||
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
|
||||
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
|
||||
|
||||
## 插件
|
||||
|
||||
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
|
||||
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
|
||||
|
||||
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
|
||||
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
|
||||
|
||||
## 社区
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
|
||||
- 微信群,扫码加入:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
|
||||
|
||||
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
|
||||
|
||||
## 赞助
|
||||
|
||||
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
|
||||
|
||||
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
|
||||
@@ -3,11 +3,13 @@
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
|
||||
//! `<home>/.codex/session_index.jsonl`).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -206,11 +208,39 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
|
||||
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
|
||||
/// so the last one wins.
|
||||
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
|
||||
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
|
||||
let mut names = HashMap::new();
|
||||
for line in std::io::BufReader::new(file).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
|
||||
let id = v.get("id").and_then(serde_json::Value::as_str);
|
||||
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
if let (Some(id), Some(name)) = (id, name) {
|
||||
names.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
|
||||
/// thread names, else by their first prompts.
|
||||
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
|
||||
let mut sessions: Vec<ExternalSession> =
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
|
||||
if !sessions.is_empty() {
|
||||
let names = codex_names(codex_home);
|
||||
for s in &mut sessions {
|
||||
if let Some(name) = names.get(&s.session_id) {
|
||||
s.title = short(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -282,6 +312,16 @@ mod tests {
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
|
||||
write(
|
||||
&tmp.path().join("session_index.jsonl"),
|
||||
&[
|
||||
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
|
||||
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
|
||||
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
|
||||
],
|
||||
);
|
||||
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1097,6 +1097,8 @@ impl AgentManager {
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
// Copying may have started the source's agent: it stops again once idle.
|
||||
source.release_when_idle();
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), task.clone());
|
||||
task.persist();
|
||||
@@ -1166,6 +1168,8 @@ impl AgentManager {
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
// Undoing may have started the agent: it stops again once idle.
|
||||
task.release_when_idle();
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,42 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Claude Code titles a session from the last 1000 characters of its text.
|
||||
const TITLE_SOURCE_CHARS: usize = 1000;
|
||||
/// Below this Claude Code gives no title without asking the model.
|
||||
const TITLE_SOURCE_MIN_CHARS: usize = 10;
|
||||
|
||||
/// The conversation's own text to title the session after: the prompts and the replies,
|
||||
/// without the files attached to the prompts. `None` while there is too little.
|
||||
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
for e in chain(entries, None) {
|
||||
if !(is_turn_start(e) || kind(e) == "assistant") {
|
||||
continue;
|
||||
}
|
||||
let blocks = match e.pointer("/message/content") {
|
||||
Some(Value::String(s)) => vec![s.as_str()],
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !t.starts_with("\n<context ref="))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for block in blocks {
|
||||
text.push_str(block);
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
let text = text.trim();
|
||||
let len = text.chars().count();
|
||||
if len < TITLE_SOURCE_MIN_CHARS {
|
||||
return None;
|
||||
}
|
||||
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -219,6 +255,29 @@ mod tests {
|
||||
assert_eq!(cut(&c, 1), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_come_from_prompts_and_replies() {
|
||||
let mut prompt = user("u1", None, "Fix the login form");
|
||||
prompt["message"]["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
|
||||
let entries = vec![
|
||||
prompt,
|
||||
assistant(
|
||||
"a1",
|
||||
"u1",
|
||||
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
|
||||
),
|
||||
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
|
||||
];
|
||||
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
|
||||
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
|
||||
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
|
||||
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_names() {
|
||||
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
|
||||
|
||||
@@ -191,6 +191,8 @@ struct State {
|
||||
limit_resets_ms: Option<f64>,
|
||||
terminal_only: Vec<String>,
|
||||
titled: bool,
|
||||
/// A title is being read or generated.
|
||||
titling: bool,
|
||||
}
|
||||
|
||||
struct Turn {
|
||||
@@ -365,6 +367,7 @@ impl Shim {
|
||||
limit_resets_ms: None,
|
||||
terminal_only: Vec::new(),
|
||||
titled: false,
|
||||
titling: false,
|
||||
}),
|
||||
cwd,
|
||||
starting: tokio::sync::Mutex::new(()),
|
||||
@@ -583,11 +586,12 @@ impl Shim {
|
||||
impl super::Shim for Shim {
|
||||
fn attach(&self, client: Peer) {
|
||||
let _ = self.client.set(client.clone());
|
||||
// The client went away: so do the processes.
|
||||
let weak = self.me.clone();
|
||||
// The client went away: so do the processes. Held until then: the connection drops
|
||||
// its handler (this shim) as it closes, and the sessions would go without stopping.
|
||||
let me = self.me.upgrade();
|
||||
tokio::spawn(async move {
|
||||
client.closed().await;
|
||||
if let Some(me) = weak.upgrade() {
|
||||
if let Some(me) = me {
|
||||
me.closed.store(true, Ordering::Release);
|
||||
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||
for s in sessions {
|
||||
@@ -598,6 +602,15 @@ impl super::Shim for Shim {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
/// A session dropped without being stopped (the shim went away) takes its process along.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.st.get_mut().proc.take() {
|
||||
p.kill.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn shim(&self) -> Option<Arc<Shim>> {
|
||||
self.shim.upgrade()
|
||||
@@ -1048,7 +1061,8 @@ impl Session {
|
||||
} else if matches!(st.start, Start::New { .. }) {
|
||||
st.start = Start::Resume { id: self.id.clone(), at: None };
|
||||
}
|
||||
read_title = !st.titled;
|
||||
read_title = !st.titled && !st.titling;
|
||||
st.titling |= read_title;
|
||||
if let Some(turn) = st.turn.as_ref()
|
||||
&& belongs(turn, &msg)
|
||||
{
|
||||
@@ -1073,13 +1087,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the title Claude Code gave the session, once it has one.
|
||||
/// Sends the session's title once it has one: the one in its transcript, or else one
|
||||
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
|
||||
/// Without a title the next turn tries again.
|
||||
fn read_title(self: &Arc<Self>) {
|
||||
let me = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Some(title) = chain_title(&me) else { return };
|
||||
me.st.lock().titled = true;
|
||||
if let Some(shim) = me.shim() {
|
||||
tokio::spawn(async move {
|
||||
let reader = me.clone();
|
||||
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
|
||||
let mut title = entries.as_deref().and_then(history::title);
|
||||
if title.is_none()
|
||||
&& let Some(source) = entries.as_deref().and_then(history::title_source)
|
||||
{
|
||||
title = me.generate_title(source).await;
|
||||
}
|
||||
{
|
||||
let mut st = me.st.lock();
|
||||
st.titling = false;
|
||||
if title.is_none() {
|
||||
return;
|
||||
}
|
||||
st.titled = true;
|
||||
}
|
||||
if let (Some(title), Some(shim)) = (title, me.shim()) {
|
||||
shim.notify(vec![Update {
|
||||
session: me.id.clone(),
|
||||
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
|
||||
@@ -1088,6 +1118,19 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
/// A title Claude Code generates from `source`, also written to the transcript.
|
||||
async fn generate_title(&self, source: String) -> Option<String> {
|
||||
let proc = self.st.lock().proc.clone()?;
|
||||
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
|
||||
match proc.control(request).await {
|
||||
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
|
||||
Err(e) => {
|
||||
tracing::debug!("generating a claude session title: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
|
||||
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
let request = msg.get("request").cloned().unwrap_or_default();
|
||||
@@ -1417,9 +1460,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
|
||||
/// The title in a session's transcript (or that of the session it continues).
|
||||
fn chain_title(session: &Session) -> Option<String> {
|
||||
history::title(&transcript_entries(session)?)
|
||||
}
|
||||
|
||||
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
|
||||
let shim = session.shim()?;
|
||||
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
|
||||
history::title(&history::read(&path).ok()?)
|
||||
history::read(&path).ok()
|
||||
}
|
||||
|
||||
fn start_json(start: &Start) -> Value {
|
||||
|
||||
@@ -311,6 +311,8 @@ struct Replay {
|
||||
sent: Arc<Mutex<Vec<Vec<Value>>>>,
|
||||
launched: Mutex<Vec<Vec<String>>>,
|
||||
errors: Arc<Mutex<Vec<String>>>,
|
||||
/// Each process's kill token.
|
||||
kills: Mutex<Vec<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Cli for Replay {
|
||||
@@ -331,6 +333,7 @@ impl Cli for Replay {
|
||||
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
|
||||
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
|
||||
let kill = CancellationToken::new();
|
||||
self.kills.lock().push(kill.clone());
|
||||
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
|
||||
sent.lock().push(Vec::new());
|
||||
tokio::spawn(async move {
|
||||
@@ -421,14 +424,18 @@ impl Replayed {
|
||||
}
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let processes = load_fixture(name);
|
||||
let cli = Arc::new(Replay {
|
||||
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
|
||||
Arc::new(Replay {
|
||||
processes: Mutex::new(processes.into()),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
kills: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let cli = replay_cli(load_fixture(name));
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h =
|
||||
@@ -461,6 +468,69 @@ async fn replay_simple() {
|
||||
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
|
||||
}
|
||||
|
||||
/// The app closing the connection (an idle task released, archived or deleted) stops the
|
||||
/// CLI processes of its sessions, idle ones included.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn closing_the_connection_stops_the_processes() {
|
||||
let r = replay("simple").await;
|
||||
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
|
||||
r.h.client.kill();
|
||||
let kills = r.cli.kills.lock().clone();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for k in kills {
|
||||
k.cancelled().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("every process was stopped");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sessions_without_a_title_get_one_generated() {
|
||||
let mut processes = load_fixture("simple");
|
||||
processes[0].extend([
|
||||
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
|
||||
"request": {"subtype": "generate_session_title"}})),
|
||||
Frame::Cli(json!({"type": "control_response",
|
||||
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
|
||||
]);
|
||||
let cli = replay_cli(processes);
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h = Harness::connect(
|
||||
cli.clone(),
|
||||
config.path().into(),
|
||||
config.path().join("pending"),
|
||||
cwd_path.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
let session = h.new_session().await;
|
||||
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
|
||||
let dir = config.path().join("projects").join(history::escape(&cwd_path));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
|
||||
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
|
||||
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
|
||||
|
||||
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
|
||||
let mut titles = Vec::new();
|
||||
for _ in 0..100 {
|
||||
titles = h.updates(&session, "session_info_update");
|
||||
if !titles.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
|
||||
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
|
||||
let sent = cli.sent.lock()[0].clone();
|
||||
let request =
|
||||
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
|
||||
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
|
||||
assert_eq!(request["request"]["persist"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn replay_multi_turn() {
|
||||
let r = replay("multi_turn").await;
|
||||
@@ -727,6 +797,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
@@ -770,6 +841,7 @@ async fn a_failed_turn_reports_its_failure() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
|
||||
@@ -28,6 +28,9 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// What a thread's name is asked for with, after its first turn.
|
||||
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
|
||||
language the user writes in. Reply with the title only, without quotes or a closing period.";
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -126,6 +129,10 @@ struct Thread {
|
||||
early_cancel: Option<Instant>,
|
||||
/// The latest agent message (a subagent's answer).
|
||||
answer: String,
|
||||
/// The thread has a name.
|
||||
named: bool,
|
||||
/// A name is being asked for.
|
||||
naming: bool,
|
||||
}
|
||||
|
||||
struct PendingPrompt {
|
||||
@@ -329,6 +336,7 @@ impl CodexShim {
|
||||
t.parent = None;
|
||||
t.settings = Some(settings);
|
||||
t.cwd = cwd;
|
||||
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
|
||||
(id, options)
|
||||
}
|
||||
|
||||
@@ -625,8 +633,9 @@ impl CodexShim {
|
||||
Some(translate::plan(params))
|
||||
}),
|
||||
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
|
||||
"thread/name/updated" => self.with_thread(thread, |_, _| {
|
||||
"thread/name/updated" => self.with_thread(thread, |t, _| {
|
||||
let title = params.get("threadName").and_then(Value::as_str)?;
|
||||
t.named = true;
|
||||
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
|
||||
}),
|
||||
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
|
||||
@@ -812,9 +821,10 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_completed(&self, thread: &str, turn: &Value) {
|
||||
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
|
||||
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
let (parent, answer) = {
|
||||
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
|
||||
let (parent, answer, name) = {
|
||||
let mut state = self.state.lock();
|
||||
let Some(t) = state.threads.get_mut(thread) else { return };
|
||||
if t.turn.as_deref().is_none_or(|r| r == id) {
|
||||
@@ -830,8 +840,15 @@ impl CodexShim {
|
||||
let _ = p.done.send((turn.clone(), error.clone()));
|
||||
}
|
||||
let answer = std::mem::take(&mut t.answer);
|
||||
(t.parent.clone(), (answer, error))
|
||||
let name =
|
||||
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
|
||||
t.naming |= name;
|
||||
(t.parent.clone(), (answer, error), name)
|
||||
};
|
||||
if name {
|
||||
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
|
||||
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
|
||||
}
|
||||
let Some(parent) = parent else { return };
|
||||
let (answer, error) = answer;
|
||||
let mut state_of = match turn.get("status").and_then(Value::as_str) {
|
||||
@@ -853,6 +870,28 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
|
||||
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
|
||||
async fn name_thread(&self, thread: &str, turn: &str) {
|
||||
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
|
||||
let named = match self.call("thread/sample", sample).await {
|
||||
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
|
||||
Some(name) => {
|
||||
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
|
||||
}
|
||||
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = &named {
|
||||
tracing::debug!("naming codex thread {thread}: {}", e.message);
|
||||
}
|
||||
if let Some(t) = self.state.lock().threads.get_mut(thread) {
|
||||
t.naming = false;
|
||||
t.named |= named.is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests from codex: approvals and questions, asked as ACP permission requests.
|
||||
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
|
||||
@@ -535,8 +535,10 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
// Older recordings predate skill discovery and thread naming; play an
|
||||
// unsupported server.
|
||||
let method = actual["method"].as_str().unwrap_or_default();
|
||||
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
@@ -574,7 +576,14 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
/// Runs `scenario` against its transcript; returns the ACP results, the client and
|
||||
/// the frames the shim sent.
|
||||
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
|
||||
let t = load(scenario.file());
|
||||
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
|
||||
(results, client, sent, meta)
|
||||
}
|
||||
|
||||
/// Both connections of a replay, which stay open while it is held.
|
||||
type CodexEnd = Box<dyn std::any::Any>;
|
||||
|
||||
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 20);
|
||||
let (tr, tw) = tokio::io::split(theirs);
|
||||
let client = Arc::new(Client::default());
|
||||
@@ -590,8 +599,8 @@ async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value)
|
||||
.await
|
||||
.expect("the scenario finished");
|
||||
// Codex stays up until the scenario is over.
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
(results, client, sent, meta)
|
||||
let (sent, read, write) = player.await.unwrap();
|
||||
(results, client, sent, meta, Box::new((acp, read, write)))
|
||||
}
|
||||
|
||||
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
|
||||
@@ -801,6 +810,42 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
|
||||
assert_eq!(info["title"], "Say hi");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_unnamed_thread_is_named_after_its_first_turn() {
|
||||
let mut t = load(Scenario::Simple.file());
|
||||
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
|
||||
let thread = "01a18300-0000-7000-8000-000000000001";
|
||||
t.frames.extend([
|
||||
(true, json!({"id": 90, "method": "thread/sample"})),
|
||||
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
|
||||
(true, json!({"id": 91, "method": "thread/name/set"})),
|
||||
(false, json!({"id": 91, "result": {}})),
|
||||
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
|
||||
]);
|
||||
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
|
||||
let session = session_of(&results, "new");
|
||||
let sample = &sent(&frames, "thread/sample")[0]["params"];
|
||||
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
|
||||
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
|
||||
let mut info = Vec::new();
|
||||
for _ in 0..100 {
|
||||
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
|
||||
if !info.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_names_are_the_first_line_of_the_reply() {
|
||||
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
|
||||
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
|
||||
assert_eq!(thread_name(" \n\"\" "), None);
|
||||
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn settings_apply_to_the_next_turn() {
|
||||
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
|
||||
|
||||
@@ -612,6 +612,16 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
|
||||
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
|
||||
}
|
||||
|
||||
/// A thread name from the model's reply to the naming prompt: its first line, without
|
||||
/// quotes or a closing period. `None` when nothing is left.
|
||||
pub(super) fn thread_name(reply: &str) -> Option<String> {
|
||||
const MAX_NAME: usize = 80;
|
||||
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
|
||||
let line = line.trim_end_matches(['.', '。']).trim();
|
||||
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
|
||||
}
|
||||
|
||||
/// How a failed turn is reported.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Failure {
|
||||
|
||||
@@ -2391,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
|
||||
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id));
|
||||
mgr.rollback(&task.id, 1).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
|
||||
mgr.fork(&task.id, None).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
|
||||
mgr.shutdown();
|
||||
|
||||
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
|
||||
|
||||
@@ -270,6 +270,8 @@ pub struct SmartKeysSettings {
|
||||
pub pair_quotes: bool,
|
||||
pub surround_selection: bool,
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
|
||||
pub simultaneous_tag_editing: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
@@ -289,6 +291,7 @@ impl Default for SmartKeysSettings {
|
||||
pair_quotes: true,
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
simultaneous_tag_editing: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
|
||||
@@ -77,6 +77,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,
|
||||
|
||||
@@ -180,6 +180,7 @@ impl Host {
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
ops::agent::move_user_mcp_values(&host);
|
||||
host
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! window they run on the host, next to the project they work on.
|
||||
use super::{Channel, Emit};
|
||||
use crate::Host;
|
||||
use crate::agent_secrets::{AgentSecrets, Kept};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
@@ -342,8 +343,77 @@ pub async fn accept_changes(
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
|
||||
/// servers keep their values in the project's file, where the project shares them.
|
||||
const USER_MCP_VALUES: &str = "mcp/user/";
|
||||
|
||||
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
|
||||
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
|
||||
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
|
||||
let mut values = Vec::new();
|
||||
for (server, cfg) in map.iter_mut() {
|
||||
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
|
||||
for (name, value) in entries.iter_mut() {
|
||||
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn has_mcp_values(map: &McpServerMap) -> bool {
|
||||
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
|
||||
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty()
|
||||
&& let Some(saved) = secrets.get(&key)
|
||||
{
|
||||
*value = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the values of `map` and empties them in it; values of servers and names no
|
||||
/// longer in it are forgotten.
|
||||
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
|
||||
let mut kept = Kept::Store;
|
||||
let mut current = HashSet::new();
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty() {
|
||||
secrets.delete(&key);
|
||||
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
|
||||
kept = Kept::LocalFile;
|
||||
}
|
||||
value.clear();
|
||||
current.insert(key);
|
||||
}
|
||||
for key in secrets.keys(USER_MCP_VALUES) {
|
||||
if !current.contains(&key) {
|
||||
secrets.delete(&key);
|
||||
}
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
|
||||
pub fn move_user_mcp_values(host: &Host) {
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
if !has_mcp_values(&user) {
|
||||
return;
|
||||
}
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
|
||||
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
let user = host.global_settings.effective().agents.mcp_servers;
|
||||
move_user_mcp_values(host);
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
let project = host.project(window).ok().filter(|p| !p.light);
|
||||
let workspace = project
|
||||
.as_ref()
|
||||
@@ -358,10 +428,15 @@ fn persist_mcp_map(
|
||||
scope: McpScope,
|
||||
map: &McpServerMap,
|
||||
) -> IdeResult<()> {
|
||||
let mut map = map.clone();
|
||||
let kept = match scope {
|
||||
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
|
||||
McpScope::Workspace => Kept::Store,
|
||||
};
|
||||
let value = if map.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
};
|
||||
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
|
||||
match scope {
|
||||
@@ -370,6 +445,9 @@ fn persist_mcp_map(
|
||||
for p in host.projects() {
|
||||
p.settings.reload();
|
||||
p.sink.emit(event.clone());
|
||||
if kept == Kept::LocalFile {
|
||||
host.agent_secrets.notify_kept_locally(&p.sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
@@ -418,15 +496,17 @@ fn listed(
|
||||
ide_agent::mcp::servers_from_settings(user, &workspace)
|
||||
}
|
||||
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
|
||||
/// user turned on.
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
|
||||
/// the project's the user turned on.
|
||||
pub(crate) fn session_mcp_servers(
|
||||
settings: &ide_workspace::SettingsStore,
|
||||
approvals: &crate::trust::McpServerApprovals,
|
||||
secrets: &AgentSecrets,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<McpServer> {
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
fill_mcp_values(secrets, &mut user);
|
||||
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
|
||||
ide_agent::mcp::servers_from_settings(&user, &workspace)
|
||||
}
|
||||
@@ -593,6 +673,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
@@ -605,6 +686,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
|
||||
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
|
||||
let secrets = AgentSecrets::new(&tmp.path().join("data"));
|
||||
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
|
||||
servers
|
||||
.into_iter()
|
||||
@@ -613,13 +695,27 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
on(session_mcp_servers(&settings, &approvals, &root)),
|
||||
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
|
||||
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
|
||||
);
|
||||
|
||||
// A user server's saved values reach the session; the file keeps only the names.
|
||||
std::fs::write(
|
||||
config.join("settings.toml"),
|
||||
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
|
||||
settings.reload();
|
||||
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
|
||||
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
|
||||
assert_eq!(keyed.env[0].value, "saved-user");
|
||||
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
|
||||
settings.reload();
|
||||
|
||||
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
|
||||
approvals.approve(&root, "extra", &extra).unwrap();
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
|
||||
McpScope::Workspace,
|
||||
"extra".to_owned(),
|
||||
"extra".to_owned()
|
||||
|
||||
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -330,21 +331,61 @@ pub async fn build_sync(
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let lsp = project.lsp.clone();
|
||||
let documents = project.documents.clone();
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
let first = updates.first().cloned();
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
let changed = {
|
||||
let lsp = lsp.clone();
|
||||
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
|
||||
};
|
||||
if let Some(file) = first
|
||||
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
|
||||
{
|
||||
tracing::debug!("openidea.java.refreshArchives: {e}");
|
||||
}
|
||||
refresh_library_classes(&lsp, &documents).await;
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
|
||||
/// again keeps its path) show the new contents, like a reopened one.
|
||||
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
|
||||
let changed = {
|
||||
let (lsp, documents) = (lsp.clone(), documents.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
|
||||
.filter(|(_, uri)| lsp.library_class_changed(uri))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (doc, uri) in changed {
|
||||
match lsp.virtual_document(&uri).await {
|
||||
Ok(vd) => {
|
||||
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
|
||||
tracing::debug!("refreshing {uri}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
@@ -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.
|
||||
|
||||
@@ -188,8 +188,14 @@ pub async fn remote_download_into(
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
/// `password`: an FTP password typed in the settings but not applied yet.
|
||||
pub async fn deploy_test_connection(
|
||||
state: &Host,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
state.remote.test_connection_with(&connection, &path, password).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
@@ -231,6 +237,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
|
||||
});
|
||||
}
|
||||
@@ -675,9 +675,10 @@ impl Host {
|
||||
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
|
||||
}
|
||||
let approvals = self.mcp_approvals.clone();
|
||||
let secrets = self.agent_secrets.clone();
|
||||
let root = root.to_path_buf();
|
||||
agents.set_mcp_servers_hook(Box::new(move || {
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
|
||||
}));
|
||||
(db, http_history, agents)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Values of the user's MCP servers' environment variables and HTTP headers live in the
|
||||
//! credential store; `settings.toml` keeps only their names. A project's own servers keep
|
||||
//! their values in the project's file, where the project shares them.
|
||||
|
||||
use ide_agent::{McpKeyValue, McpScope, McpServer, McpServerInput};
|
||||
use ide_core::NullSink;
|
||||
use ide_core::settings::McpTransport;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VALUE: &str = "mcp-value-0f3c9a";
|
||||
|
||||
fn host(dir: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: dir.join("config"),
|
||||
data_dir: dir.join("data"),
|
||||
local_data_dir: dir.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn project(tmp: &tempfile::TempDir) -> PathBuf {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn open(host: &Host, root: &Path) {
|
||||
host.trust.decide(root, true, false).unwrap();
|
||||
host.open_project("main", root).unwrap();
|
||||
}
|
||||
|
||||
fn header(servers: &[McpServer], id: &str) -> String {
|
||||
let server = servers.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} in {servers:?}"));
|
||||
server.headers.iter().find(|h| h.name == "Authorization").map(|h| h.value.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn input(name: &str, scope: McpScope) -> McpServerInput {
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: name.into(),
|
||||
scope,
|
||||
transport: McpTransport::Http,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: vec![],
|
||||
env: vec![],
|
||||
url: Some("https://mcp.example/v1".into()),
|
||||
headers: vec![McpKeyValue { name: "Authorization".into(), value: format!("Bearer {VALUE}") }],
|
||||
agents: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_WITH_VALUE: &str = r#"
|
||||
[agents.mcpServers.remote]
|
||||
transport = "http"
|
||||
enabled = true
|
||||
url = "https://mcp.example/v1"
|
||||
headers = { Authorization = "Bearer mcp-value-0f3c9a" }
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_server_values_stay_out_of_the_user_settings() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
for (name, scope) in [("user-server", McpScope::User), ("repo-server", McpScope::Workspace)] {
|
||||
ide_host::ops::agent::mcp_save(&host, "main", input(name, scope)).unwrap();
|
||||
}
|
||||
host.close_project("main");
|
||||
|
||||
// Only the project's own file holds a value; nothing in the user's config or data does.
|
||||
let found = files_containing(tmp.path(), VALUE);
|
||||
assert!(found.len() == 1 && found[0].ends_with(Path::new(".openidea/settings.toml")), "{found:?}");
|
||||
|
||||
let host = self::host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::user-server"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(header(&servers, "workspace::repo-server"), format!("Bearer {VALUE}"));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn values_in_user_settings_move_to_the_credential_store() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
|
||||
let saved = SERVER_WITH_VALUE.replace("Authorization =", "X-Saved = \"\", Authorization =");
|
||||
std::fs::write(tmp.path().join("config/settings.toml"), saved).unwrap();
|
||||
ide_host::agent_secrets::AgentSecrets::new(&tmp.path().join("data"))
|
||||
.set("mcp/user/remote/header/X-Saved", "saved-earlier")
|
||||
.unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::remote"), format!("Bearer {VALUE}"));
|
||||
let remote = servers.iter().find(|s| s.id == "user::remote").unwrap();
|
||||
assert!(remote.headers.iter().any(|h| h.name == "X-Saved" && h.value == "saved-earlier"), "{remote:?}");
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_projects_own_values_stay_in_its_file() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let file = root.join(".openidea/settings.toml");
|
||||
std::fs::write(&file, SERVER_WITH_VALUE).unwrap();
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), SERVER_WITH_VALUE, "reading leaves the project file alone");
|
||||
|
||||
// Saving the servers keeps the project's values in the project's file for everyone else.
|
||||
let servers = ide_host::ops::agent::mcp_set_enabled(&host, "main", "workspace::remote".into(), false).unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains(VALUE));
|
||||
assert_eq!(files_containing(&tmp.path().join("data"), VALUE), Vec::<PathBuf>::new());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -2609,6 +2609,82 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
assert!(err.contains("Restricted Mode"), "{err}");
|
||||
}
|
||||
|
||||
/// Git Toolbox in a workspace window whose container folder is not a
|
||||
/// repository (every member is its own clone): the status bar follows the
|
||||
/// focused file's repository, and the scheduled fetch covers every member.
|
||||
#[test]
|
||||
fn official_git_toolbox_follows_members_in_a_workspace() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A remote with `main`, cloned by member `a` (at `main`) and member `b`
|
||||
// (on its own branch, to tell the two apart in the status bar).
|
||||
let remote = tmp.path().join("remote.git");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
git(&remote, &["init", "--bare", "-b", "main"]);
|
||||
let seed = tmp.path().join("seed");
|
||||
std::fs::create_dir(&seed).unwrap();
|
||||
git(&seed, &["init", "-b", "main"]);
|
||||
std::fs::write(seed.join("seed.txt"), "seed\n").unwrap();
|
||||
git(&seed, &["add", "."]);
|
||||
git(&seed, &["commit", "-m", "one"]);
|
||||
git(&seed, &["remote", "add", "origin", remote.to_str().unwrap()]);
|
||||
git(&seed, &["push", "-u", "origin", "main"]);
|
||||
let member_a = tmp.path().join("member-a");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_a.to_str().unwrap()]);
|
||||
let member_a = dunce::canonicalize(member_a).unwrap();
|
||||
let member_b = tmp.path().join("member-b");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_b.to_str().unwrap()]);
|
||||
let member_b = dunce::canonicalize(member_b).unwrap();
|
||||
git(&member_b, &["checkout", "-b", "feature"]);
|
||||
git(&member_b, &["push", "-u", "origin", "feature"]);
|
||||
// The window's project is the container; the members are attached to it.
|
||||
f.workspace.other_roots.lock().extend([member_a.clone(), member_b.clone()]);
|
||||
|
||||
f.install(&official("git-toolbox"));
|
||||
let item = |host: &PluginHost| {
|
||||
host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch")
|
||||
};
|
||||
let now = Instant::now();
|
||||
let focus = |path: &Path| {
|
||||
f.host
|
||||
.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(path.to_string_lossy().into_owned()))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
f.host.wait_events(Duration::from_secs(10));
|
||||
};
|
||||
// Focusing a file of member `a` shows that member's branch: the window's
|
||||
// project is the container, which is no repository.
|
||||
focus(&member_a.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into()));
|
||||
// Fetch Now fetches the shown member; the container is not a repository.
|
||||
f.run("openidea.git-toolbox", "fetch", None).unwrap();
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(&member_a))),
|
||||
"{member_a:?}: {fetched:?}"
|
||||
);
|
||||
assert_eq!(fetched.iter().filter(|(key, _)| key.starts_with("last-fetch:")).count(), 1, "{fetched:?}");
|
||||
// Switching to the other member follows it.
|
||||
focus(&member_b.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
|
||||
// Idle: reclaimed; the schedule then wakes it to fetch every member (the
|
||||
// container is skipped: it has no repository).
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
f.host.tick();
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()), "the item stays while it sleeps");
|
||||
f.take_events();
|
||||
f.host.tick_at(Instant::now() + Duration::from_secs(301));
|
||||
assert!(f.host.wait_events(Duration::from_secs(30)));
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
for member in [&member_a, &member_b] {
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(member))),
|
||||
"{member:?}: {fetched:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
}
|
||||
|
||||
/// A tiny HTTP server: each request is answered after `delay(n)` (n counts
|
||||
/// requests) with `body`. Returns its port and the request bodies it got.
|
||||
fn http_server(
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
|
||||
host.remote.test_connection(&connection, "/projects").await.unwrap();
|
||||
let local = tmp.path().join("local-deployment");
|
||||
std::fs::create_dir(&local).unwrap();
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -80,6 +80,11 @@ impl RemoteService {
|
||||
}
|
||||
|
||||
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
|
||||
self.fs_with(connection, None).await
|
||||
}
|
||||
|
||||
/// Like [`Self::fs`], with an FTP password typed but not saved yet (`None`: the saved one).
|
||||
async fn fs_with(&self, connection: &FsConnection, password: Option<String>) -> IdeResult<RemoteFs> {
|
||||
match connection {
|
||||
FsConnection::Local => Ok(RemoteFs::Local),
|
||||
FsConnection::Sftp { ssh } => {
|
||||
@@ -90,14 +95,24 @@ impl RemoteService {
|
||||
Ok(RemoteFs::new(session.sftp().await?).with_shell(session))
|
||||
}
|
||||
FsConnection::Ftp { server, secure, ftp } => {
|
||||
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
|
||||
let password = password.or_else(|| self.ssh.password(&ide_ssh::ftp::secret_key(server)));
|
||||
Ok(RemoteFs::Ftp(ide_ssh::ftp::FtpFs::connect(ftp, *secure, password).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
self.test_connection_with(connection, path, None).await
|
||||
}
|
||||
|
||||
/// Tests `connection` with an FTP password the settings have not saved yet.
|
||||
pub async fn test_connection_with(
|
||||
&self,
|
||||
connection: &FsConnection,
|
||||
path: &str,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let fs = self.fs_with(connection, password).await?;
|
||||
let result = fs.test(path).await;
|
||||
fs.close().await;
|
||||
result
|
||||
|
||||
@@ -377,6 +377,11 @@ fn gradle_run_jdk<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest installed JDK that Gradle `gradle` runs on.
|
||||
fn newest_gradle_jdk(gradle: (u32, u32), installed: &[crate::java::Jdk]) -> Option<&crate::java::Jdk> {
|
||||
installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).max_by_key(|jdk| jdk.major)
|
||||
}
|
||||
|
||||
/// Default environment shared by Gradle model builds and Run configurations.
|
||||
pub fn gradle_build_env(
|
||||
root: &Path,
|
||||
@@ -493,7 +498,17 @@ pub fn configure_gradle_runtime(cfg: &mut LanguageServerConfig, root: &Path, bui
|
||||
.any(|f| build_dir.join(f).is_file());
|
||||
let env = if gradle_build {
|
||||
let command = gradle_command(root, build_dir, s);
|
||||
gradle_build_env(root, build_dir, &command.to_string_lossy(), s, None)
|
||||
let command = command.to_string_lossy();
|
||||
let mut env = gradle_build_env(root, build_dir, &command, s, None);
|
||||
// No Project JDK and no Java version the build declares: jdtls would import on its own
|
||||
// runtime, which an older Gradle cannot run on. Use an installed JDK this Gradle supports.
|
||||
if !env.contains_key("JAVA_HOME")
|
||||
&& let Some(gradle) = gradle_distribution_version(build_dir, &command)
|
||||
&& let Some(jdk) = newest_gradle_jdk(gradle, &crate::java::installed_jdks())
|
||||
{
|
||||
env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
env
|
||||
} else {
|
||||
gradle_env(root, s, Default::default())
|
||||
};
|
||||
@@ -2028,6 +2043,11 @@ mod gradle_runtime_tests {
|
||||
assert_eq!(gradle_run_jdk(Some(21), None, &installed, None).unwrap().major, 21);
|
||||
assert_eq!(gradle_run_jdk(Some(19), None, &installed, None).unwrap().major, 21);
|
||||
assert!(gradle_run_jdk(None, None, &installed, None).is_none());
|
||||
// The Java import of a build that declares no Java version still needs a JVM its Gradle runs on.
|
||||
assert_eq!(newest_gradle_jdk((8, 4), &installed).unwrap().major, 17);
|
||||
assert_eq!(newest_gradle_jdk((7, 2), &installed).unwrap().major, 11);
|
||||
assert_eq!(newest_gradle_jdk((9, 1), &installed).unwrap().major, 25);
|
||||
assert!(newest_gradle_jdk((9, 0), &installed[4..]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.13.0",
|
||||
"1.14.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ impl SshManager {
|
||||
self.entered.lock().get(key).cloned().or_else(|| self.saved.get(key))
|
||||
}
|
||||
|
||||
/// Whether a password is saved under `key` (not one only entered this session).
|
||||
pub fn password_saved(&self, key: &str) -> bool {
|
||||
self.saved.get(key).is_some()
|
||||
}
|
||||
|
||||
/// The known password / passphrase of configuration `id`.
|
||||
pub fn secret(&self, config: &SshConfiguration) -> Option<String> {
|
||||
if let Some(s) = self.entered.lock().get(&config.id) {
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+43
-14
@@ -71,7 +71,14 @@ async function openReply(page: Page, text: string, imageData?: string, theme?: "
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{
|
||||
type: "user",
|
||||
text: "Show Markdown",
|
||||
attachments: [],
|
||||
sentMs: 0,
|
||||
doneMs: 1,
|
||||
steered: false,
|
||||
},
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
@@ -111,7 +118,11 @@ test("scrolls a wide table inside the reply", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const sizes = await reply.evaluate((el) => {
|
||||
const table = el.querySelector("table")!;
|
||||
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
|
||||
return {
|
||||
reply: el.clientWidth,
|
||||
table: table.clientWidth,
|
||||
content: table.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
|
||||
expect(sizes.content).toBeGreaterThan(sizes.table);
|
||||
@@ -148,7 +159,10 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
let fetched = 0;
|
||||
await page.route("https://images.example.com/code.gif", (route) => {
|
||||
fetched++;
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
});
|
||||
const reply = await openReply(
|
||||
page,
|
||||
@@ -158,7 +172,9 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
|
||||
const width = (name: string) =>
|
||||
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
|
||||
await expect.poll(() => width("Local QR")).toBe(1);
|
||||
const load = reply.getByRole("button", { name: "Load image from images.example.com" });
|
||||
const load = reply.getByRole("button", {
|
||||
name: "Load image from images.example.com",
|
||||
});
|
||||
await expect(load).toBeVisible();
|
||||
expect(fetched).toBe(0);
|
||||
await load.click();
|
||||
@@ -203,12 +219,10 @@ test("keeps latest reply actions visible and quotes into the draft without shift
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
|
||||
const quoted = REPLY.trimEnd().split("\n");
|
||||
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
|
||||
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
|
||||
});
|
||||
|
||||
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
|
||||
@@ -231,7 +245,14 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
const user = (text: string) => ({
|
||||
type: "user",
|
||||
text,
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 5001,
|
||||
steered: false,
|
||||
});
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
@@ -326,7 +347,9 @@ test("keeps hover actions reachable when the pointer leaves the message body", a
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
@@ -468,7 +491,10 @@ for (const theme of ["dark", "light"] as const) {
|
||||
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
|
||||
const fg = luminance(getComputedStyle(el).fill);
|
||||
const bg = luminance(getComputedStyle(bar).fill);
|
||||
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
|
||||
return {
|
||||
text: el.textContent,
|
||||
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
|
||||
};
|
||||
});
|
||||
});
|
||||
expect(contrasts).toHaveLength(4);
|
||||
@@ -480,7 +506,10 @@ test("draws a diagram without reaching the hosts its configuration names", async
|
||||
const asked: string[] = [];
|
||||
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
|
||||
asked.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
|
||||
return route.fulfill({
|
||||
contentType: "image/gif",
|
||||
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
|
||||
});
|
||||
});
|
||||
const at = (name: string) => `https://images.example.com/${name}`;
|
||||
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
|
||||
d6991b9065d8b623efdf9c03fb92b4875e95e666b3dadb3925ff5ad327f5fdcb
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Git Toolbox for OpenIDEA: background fetch of every open project's
|
||||
//! repository (on the plugin's `on-schedule`, which also fires after the
|
||||
//! instance was reclaimed while idle), and the focused project's branch with
|
||||
//! instance was reclaimed while idle), and the focused file's branch with
|
||||
//! its ahead/behind counts in the status bar.
|
||||
|
||||
use openidea_plugin::host::{self, storage};
|
||||
@@ -14,7 +14,12 @@ const LAST_FETCH: &str = "last-fetch:";
|
||||
const SHOWN: &str = "shown-root";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GitToolbox;
|
||||
pub struct GitToolbox {
|
||||
/// The focused editor's file path, from the last `editor-focused` event.
|
||||
/// Kept across events: the status bar keeps following that file's
|
||||
/// repository until another file takes focus.
|
||||
focused: Option<String>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
@@ -67,31 +72,51 @@ fn tooltip(s: &RepositoryStatus) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Shows `project`'s repository in the status bar (or hides the item).
|
||||
fn show(project: &Project) -> Result<(), Error> {
|
||||
match project.vcs_status()? {
|
||||
Some(s) => {
|
||||
/// Shows the first of `projects` that has a repository in the status bar (the
|
||||
/// item hides when none has). Returns the index of the one shown.
|
||||
fn show(projects: &[Project]) -> Result<Option<usize>, Error> {
|
||||
for (i, project) in projects.iter().enumerate() {
|
||||
if let Some(s) = project.vcs_status()? {
|
||||
let _ = storage::set(SHOWN, &project.root()?);
|
||||
host::set_status_item(
|
||||
ITEM,
|
||||
&StatusItem { text: label(&s), tooltip: Some(tooltip(&s)), command: Some("fetch".into()) },
|
||||
);
|
||||
return Ok(Some(i));
|
||||
}
|
||||
None => host::remove_status_item(ITEM),
|
||||
}
|
||||
Ok(())
|
||||
host::remove_status_item(ITEM);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The project the status bar follows: the invoking window's, else the one
|
||||
/// shown last, else the first open one.
|
||||
fn current(ctx: &Invocation) -> Option<Project> {
|
||||
if let Some(p) = ctx.project() {
|
||||
return Some(p);
|
||||
impl GitToolbox {
|
||||
/// The projects the status bar may follow, most specific first: the
|
||||
/// focused file's repository (a workspace window's project is the
|
||||
/// container folder, and its focused files belong to member
|
||||
/// repositories), the invoking window's, the one shown last, else the
|
||||
/// first open one.
|
||||
fn candidates(&self, ctx: &Invocation) -> Vec<Project> {
|
||||
let mut out: Vec<Project> = Vec::new();
|
||||
let editor = ctx.editor().map(|e| e.path());
|
||||
let mut push = |project: Option<Project>| {
|
||||
if let Some(p) = project
|
||||
&& let Ok(root) = p.root()
|
||||
&& out.iter().all(|q| q.root() != Ok(root.clone()))
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
push(self.focused.as_deref().or(editor.as_deref()).and_then(host::project_of));
|
||||
push(ctx.project());
|
||||
let projects = host::projects().unwrap_or_default();
|
||||
let shown = storage::get(SHOWN).ok().flatten();
|
||||
let index = projects.iter().position(|p| p.root().ok().as_deref() == shown.as_deref()).unwrap_or(0);
|
||||
let mut rest = projects;
|
||||
if index < rest.len() {
|
||||
push(Some(rest.swap_remove(index)));
|
||||
}
|
||||
out
|
||||
}
|
||||
let projects = host::projects().ok()?;
|
||||
let shown = storage::get(SHOWN).ok().flatten();
|
||||
let index = projects.iter().position(|p| p.root().ok() == shown).unwrap_or(0);
|
||||
projects.into_iter().nth(index)
|
||||
}
|
||||
|
||||
/// Fetches `projects` in one cancellable task. Returns how many were fetched.
|
||||
@@ -131,15 +156,22 @@ fn fetch(projects: &[Project]) -> Result<usize, Error> {
|
||||
|
||||
impl Plugin for GitToolbox {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
let project = current(ctx).ok_or("no project is open")?;
|
||||
let projects = self.candidates(ctx);
|
||||
if projects.is_empty() {
|
||||
return Err("no project is open".into());
|
||||
}
|
||||
match id {
|
||||
"fetch" => {
|
||||
let n = fetch(std::slice::from_ref(&project))?;
|
||||
show(&project)?;
|
||||
// Only the repository the status bar shows, not every candidate.
|
||||
let Some(i) = show(&projects)? else {
|
||||
return Ok(Some("The project is not in a git repository.".to_owned()));
|
||||
};
|
||||
let n = fetch(std::slice::from_ref(&projects[i]))?;
|
||||
show(&projects)?;
|
||||
Ok((n == 0).then(|| "The project is not in a git repository.".to_owned()))
|
||||
}
|
||||
"refresh" => {
|
||||
show(&project)?;
|
||||
show(&projects)?;
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
@@ -155,9 +187,12 @@ impl Plugin for GitToolbox {
|
||||
host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(p) = current(ctx) {
|
||||
let _ = show(&p);
|
||||
for e in events {
|
||||
if let Event::EditorFocused(Some(path)) = e {
|
||||
self.focused = Some(path.clone());
|
||||
}
|
||||
}
|
||||
let _ = show(&self.candidates(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +230,7 @@ mod tests {
|
||||
let host = MockHost::install();
|
||||
host.add_project("/other");
|
||||
host.set_vcs_status(Some(status(0, 4)));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.fetches(), 2, "every open project");
|
||||
let item = &host.status_items()["branch"];
|
||||
assert_eq!(item.text, "main ↓4");
|
||||
@@ -208,26 +243,72 @@ mod tests {
|
||||
#[test]
|
||||
fn focus_changes_only_refresh() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = GitToolbox::default();
|
||||
host.set_vcs_status(Some(status(1, 0)));
|
||||
GitToolbox.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.fetches(), 0);
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↑1");
|
||||
host.set_vcs_status(None);
|
||||
GitToolbox.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
|
||||
plugin.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
|
||||
assert!(host.status_items().is_empty(), "not a repository any more");
|
||||
}
|
||||
|
||||
/// A workspace window's project is the container folder, which is no
|
||||
/// repository: the focused file's member repository is shown instead, and
|
||||
/// follows the focus between members.
|
||||
#[test]
|
||||
fn workspace_windows_follow_the_focused_members_repository() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = GitToolbox::default();
|
||||
host.set_project_root(Some("/workspace"));
|
||||
host.add_project("/repos/a");
|
||||
host.add_project("/repos/b");
|
||||
host.set_vcs_status(Some(status(1, 2)));
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/repos/a/src/main.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/a", "the focused file's member, not the container");
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↑1 ↓2");
|
||||
plugin.handle_events(&[Event::EditorFocused(Some("/repos/b/src/lib.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/b", "the focus moved to the other member");
|
||||
// A scheduled tick keeps following the focused member.
|
||||
plugin.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/repos/b");
|
||||
}
|
||||
|
||||
/// Fetch Now fetches the repository the status bar shows, not every
|
||||
/// other open project.
|
||||
#[test]
|
||||
fn fetch_now_fetches_only_the_shown_repository() {
|
||||
let host = MockHost::install();
|
||||
host.add_project("/other");
|
||||
storage::set(SHOWN, "/other").unwrap();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
GitToolbox::default().run_command("fetch", &host.invocation(), None).unwrap();
|
||||
assert_eq!(host.fetches(), 1);
|
||||
assert_eq!(host.storage()["shown-root"], "/project");
|
||||
}
|
||||
|
||||
/// A focused path outside every open project falls back to the window's
|
||||
/// project, whose repository is shown as before.
|
||||
#[test]
|
||||
fn an_unresolvable_focused_path_falls_back_to_the_window_project() {
|
||||
let host = MockHost::install();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
GitToolbox::default().handle_events(&[Event::EditorFocused(Some("src/main.rs".into()))], &host.invocation());
|
||||
assert_eq!(host.storage()["shown-root"], "/project");
|
||||
assert_eq!(host.status_items()["branch"].text, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_and_cancellation() {
|
||||
let host = MockHost::install();
|
||||
host.set_vcs_status(Some(status(0, 0)));
|
||||
host.fail_fetch(Some(Error::Failed("no network".into())));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.notifications()[0].body, "no network");
|
||||
let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err();
|
||||
let err = GitToolbox::default().run_command("fetch", &host.invocation(), None).unwrap_err();
|
||||
assert_eq!(err, Error::Failed("no network".into()));
|
||||
host.fail_fetch(Some(Error::Cancelled));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report");
|
||||
}
|
||||
|
||||
@@ -235,7 +316,7 @@ mod tests {
|
||||
fn without_vcs_write_it_still_shows_the_branch() {
|
||||
let host = MockHost::install().with_capabilities(&["project.read", "vcs.read", "storage"]);
|
||||
host.set_vcs_status(Some(status(0, 1)));
|
||||
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
|
||||
GitToolbox::default().handle_events(&[Event::Scheduled], &host.invocation());
|
||||
assert!(host.notifications()[0].body.contains("vcs.write"));
|
||||
assert_eq!(host.status_items()["branch"].text, "main ↓1");
|
||||
}
|
||||
|
||||
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -262,6 +264,7 @@ from `type`, then map:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -189,22 +189,44 @@ pub fn settings_get_global(state: State<'_, AppState>) -> Settings {
|
||||
#[specta::specta]
|
||||
pub fn settings_set(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
scope: SettingsScope,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> IdeResult<Settings> {
|
||||
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?;
|
||||
let window = window.label().to_owned();
|
||||
// The merge validates its result by deserializing the whole `Settings`, which needs
|
||||
// ~250 KiB of stack in a debug build (measured by bisecting a thread's stack size; the
|
||||
// same with and without a settings file, so it is the type, not the data). A WebView2
|
||||
// IPC callback reaches this command with ~750 KiB of the main thread's 1 MiB already
|
||||
// used, so the merge — and anything else called from that callback — can overflow it.
|
||||
// A worker thread starts from a clean stack, where the default 2 MiB is ample.
|
||||
std::thread::spawn(move || {
|
||||
let state = app.state::<AppState>();
|
||||
settings_set_now(&state, &window, scope, &key, &value)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| IdeError::internal("settings worker panicked"))?
|
||||
}
|
||||
|
||||
fn settings_set_now(
|
||||
state: &AppState,
|
||||
window: &str,
|
||||
scope: SettingsScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> IdeResult<Settings> {
|
||||
let applied = ide_host::ops::project::set_settings(&state.host, window, scope, key, value)?;
|
||||
if key == "mcpServer" || key.starts_with("mcpServer.") {
|
||||
crate::commands::mcp::apply(&state);
|
||||
crate::commands::mcp::apply(state);
|
||||
}
|
||||
if !applied.project_scope {
|
||||
state.updates.wake.notify_one();
|
||||
}
|
||||
// A project value concerns only that project's window (and its settings window).
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key };
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key: key.to_owned() };
|
||||
if applied.project_scope {
|
||||
let owner = AppState::owner_window(window.label());
|
||||
let owner = AppState::owner_window(window);
|
||||
for label in [owner.to_owned(), crate::windows::settings_label(owner)] {
|
||||
let sink = crate::events::TauriSink { app: state.app.clone(), window: Some(label) };
|
||||
sink.emit(event.clone());
|
||||
@@ -227,6 +249,7 @@ pub async fn settings_export(
|
||||
path: PathBuf,
|
||||
storage: std::collections::BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::agent::move_user_mcp_values(&state.host);
|
||||
state.global_settings.export_bundle(&path, storage)
|
||||
}
|
||||
|
||||
@@ -244,6 +267,7 @@ pub struct SettingsImport {
|
||||
#[specta::specta]
|
||||
pub async fn settings_import(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<SettingsImport> {
|
||||
let (_, storage) = state.global_settings.import_bundle(&path)?;
|
||||
ide_host::ops::agent::move_user_mcp_values(&state.host);
|
||||
for project in state.projects() {
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
|
||||
@@ -158,6 +158,7 @@ pub async fn deploy_test_connection(
|
||||
state: State<'_, AppState>,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await
|
||||
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path, password).await
|
||||
}
|
||||
@@ -676,6 +676,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
wsl::wsl_resolve,
|
||||
ssh::ssh_set_secret,
|
||||
ssh::ssh_needs_secret,
|
||||
ssh::ssh_password_saved,
|
||||
ssh::ssh_trust_host,
|
||||
ssh::ssh_connected,
|
||||
ssh::ssh_disconnect,
|
||||
|
||||
@@ -27,6 +27,13 @@ pub fn ssh_set_secret(state: State<'_, AppState>, id: String, secret: Option<Str
|
||||
state.ssh.set_secret(&id, secret, save);
|
||||
}
|
||||
|
||||
/// Whether a password is saved under `key` (an FTP deployment server's).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn ssh_password_saved(state: State<'_, AppState>, key: String) -> bool {
|
||||
state.ssh.password_saved(&key)
|
||||
}
|
||||
|
||||
/// Whether connecting with configuration `id` needs a password first.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
+16
-2
@@ -1594,6 +1594,8 @@ export const commands = {
|
||||
sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE<void>("ssh_set_secret", { id, secret, save }),
|
||||
/** Whether connecting with configuration `id` needs a password first. */
|
||||
sshNeedsSecret: (id: string) => typedError<boolean, IdeError>(__TAURI_INVOKE("ssh_needs_secret", { id })),
|
||||
/** Whether a password is saved under `key` (an FTP deployment server's). */
|
||||
sshPasswordSaved: (key: string) => __TAURI_INVOKE<boolean>("ssh_password_saved", { key }),
|
||||
/** Trusts the host key that failed the last connection of configuration `id`. */
|
||||
sshTrustHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("ssh_trust_host", { id })),
|
||||
/** Ids of the configurations with an open connection. */
|
||||
@@ -1623,7 +1625,7 @@ export const commands = {
|
||||
} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })),
|
||||
deployServerRoot: (server: string) => typedError<DeploymentRoot, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
|
||||
remoteList: (connection: FsConnection, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { connection, path })),
|
||||
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
|
||||
deployTestConnection: (connection: FsConnection, path: string, password: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path, password })),
|
||||
remoteOpen: (connection: FsConnection, path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("remote_open", { connection, path })),
|
||||
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
|
||||
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
|
||||
@@ -2502,7 +2504,7 @@ export type AskpassKind =
|
||||
/** Yes / no (an unknown host key); answered with `yes` or `no`. */
|
||||
"confirm";
|
||||
|
||||
export type AttachKind = "java" | "node" | "python" | "go" | "native";
|
||||
export type AttachKind = "java" | "node" | "python" | "dotnet" | "go" | "native";
|
||||
|
||||
export type AttachableProcess = {
|
||||
pid: number,
|
||||
@@ -4090,6 +4092,14 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4133,6 +4143,8 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
@@ -9595,6 +9607,8 @@ export type SmartKeysSettings = {
|
||||
pairQuotes?: boolean,
|
||||
surroundSelection?: boolean,
|
||||
tabOutOfPair?: boolean,
|
||||
/** Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag. */
|
||||
simultaneousTagEditing?: boolean,
|
||||
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
|
||||
reformatOnPaste?: string,
|
||||
escapeOnPaste?: boolean,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerEditorKind, type EditorKindContribution } from "@/workbench/contributions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import type * as Messages from "@/workbench/messages";
|
||||
import { EditorController, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof Messages>()),
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
|
||||
let controller: EditorController | null = null;
|
||||
afterEach(() => {
|
||||
controller?.stop();
|
||||
controller = null;
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
useProject.setState({ project: null, settings: null });
|
||||
vi.mocked(confirm).mockReset();
|
||||
});
|
||||
|
||||
/** A kind whose tabs answer `isBusy`; the title is the input key. */
|
||||
const busyKind = (isBusy: EditorKindContribution["isBusy"]) =>
|
||||
registerEditorKind({ kind: "busy-probe", title: (i) => i.key, component: () => null, isBusy });
|
||||
|
||||
const start = () => {
|
||||
controller = new EditorController(createMemoryBackend({}));
|
||||
controller.start();
|
||||
return controller;
|
||||
};
|
||||
const pane = () => useTabs.getState().panes[useTabs.getState().activePane]!;
|
||||
const keys = () => pane().tabs.map((tab) => tab.input.key);
|
||||
const question = (name: string) => ({
|
||||
title: t("editor.closeBusy.title", { name }),
|
||||
message: t("editor.closeBusy.message", { name }),
|
||||
ok: t("editor.closeBusy.terminate"),
|
||||
});
|
||||
|
||||
describe("editor kinds' isBusy on close", () => {
|
||||
it("asks before closing a busy tab and keeps it on Cancel", async () => {
|
||||
const dispose = busyKind(() => true);
|
||||
vi.mocked(confirm).mockResolvedValue(false);
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
expect(keys()).toEqual(["busy-probe:1"]);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the busy tab once terminating is confirmed", async () => {
|
||||
const closed = vi.fn();
|
||||
const dispose = registerEditorKind({
|
||||
kind: "busy-probe",
|
||||
title: (i) => i.key,
|
||||
component: () => null,
|
||||
isBusy: () => true,
|
||||
onClose: closed,
|
||||
});
|
||||
vi.mocked(confirm).mockResolvedValue(true);
|
||||
const ctrl = start();
|
||||
const input = { kind: "busy-probe", key: "busy-probe:1" };
|
||||
try {
|
||||
await ctrl.openInput(input);
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(true);
|
||||
expect(keys()).toEqual([]);
|
||||
expect(closed).toHaveBeenCalledExactlyOnceWith(input);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("awaits an async answer, and closes a tab that is not busy without asking", async () => {
|
||||
const dispose = busyKind(async (i) => i.key === "busy-probe:1");
|
||||
vi.mocked(confirm).mockResolvedValue(false);
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
const idle = pane().tabs.find((tab) => tab.input.key === "busy-probe:2")!;
|
||||
expect(await ctrl.closeTab(pane().id, idle.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a failing isBusy as not busy, so the tab still closes", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const dispose = busyKind(() => {
|
||||
throw new Error("probe failed");
|
||||
});
|
||||
const ctrl = start();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
expect(await ctrl.closeTab(pane().id, pane().tabs[0]!.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
dispose();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks only when the last tab of a busy input closes", async () => {
|
||||
const dispose = busyKind(() => true);
|
||||
vi.mocked(confirm).mockResolvedValue(true);
|
||||
const ctrl = start();
|
||||
const input = { kind: "busy-probe", key: "busy-probe:1" };
|
||||
try {
|
||||
await ctrl.openInput(input);
|
||||
ctrl.split(useTabs.getState().activePane, "row");
|
||||
const panes = Object.values(useTabs.getState().panes);
|
||||
// Another pane still shows the input: nothing ends, nothing to ask.
|
||||
expect(await ctrl.closeTab(panes[0]!.id, panes[0]!.tabs[0]!.id)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(await ctrl.closeInput(input.key)).toBe(true);
|
||||
expect(confirm).toHaveBeenCalledExactlyOnceWith(question("busy-probe:1"));
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor kinds' isBusy on tab-limit eviction", () => {
|
||||
const limitOne = () =>
|
||||
useProject.setState({ settings: { editor: { tabs: { tabLimit: 1, closingPolicy: "lru" } } } as never });
|
||||
|
||||
it.each([
|
||||
["a sync answer", (key: string) => key === "busy-probe:1"],
|
||||
["an async answer", (key: string) => Promise.resolve(key === "busy-probe:1")],
|
||||
])("skips a busy tab without asking (%s)", async (_name, busy) => {
|
||||
const dispose = busyKind((i) => busy(i.key));
|
||||
const ctrl = start();
|
||||
limitOne();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:3" });
|
||||
// The least recently used tab is the busy one: it stays, the idle one makes room instead.
|
||||
expect(keys()).toEqual(["busy-probe:1", "busy-probe:3"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts the least recently used tab when none is busy", async () => {
|
||||
const dispose = busyKind(() => false);
|
||||
const ctrl = start();
|
||||
limitOne();
|
||||
try {
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:1" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:2" });
|
||||
await ctrl.openInput({ kind: "busy-probe", key: "busy-probe:3" });
|
||||
expect(keys()).toEqual(["busy-probe:3"]);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { history, redo, undo } from "@codemirror/commands";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { prefsExtension } from "./prefs";
|
||||
import { angular } from "./angular";
|
||||
import { astro } from "./astro";
|
||||
import { django } from "./django";
|
||||
import { jsp } from "./jsp";
|
||||
import { vueLanguage } from "./vue";
|
||||
import { validateEditorExtras } from "@/platform/editorSettings";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
const extensions: Extension = [html(), linkedTags];
|
||||
|
||||
/** `|` marks carets, `[`/`]` a selection. */
|
||||
function make(text: string, extra: Extension = []) {
|
||||
return makeWith([EditorState.allowMultipleSelections.of(true), extensions, extra], text);
|
||||
}
|
||||
|
||||
/** Like `make`, with the full extension list spelled out. */
|
||||
function makeWith(extensions: Extension, text: string) {
|
||||
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [];
|
||||
let doc = "";
|
||||
let anchor = -1;
|
||||
for (const ch of text) {
|
||||
if (ch === "|") ranges.push(EditorSelection.cursor(doc.length));
|
||||
else if (ch === "[") anchor = doc.length;
|
||||
else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length));
|
||||
else doc += ch;
|
||||
}
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
|
||||
extensions,
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function show(view: EditorView): string {
|
||||
const { doc, selection } = view.state;
|
||||
let out = doc.toString();
|
||||
for (const r of [...selection.ranges].sort((a, b) => b.from - a.from))
|
||||
out = r.empty
|
||||
? out.slice(0, r.head) + "|" + out.slice(r.head)
|
||||
: out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Types `text` at every caret / over every selection, as typing does. */
|
||||
function type(view: EditorView, text: string) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: r.from, to: r.to, insert: text },
|
||||
range: EditorSelection.cursor(r.from + text.length),
|
||||
})),
|
||||
{ userEvent: "input.type" },
|
||||
);
|
||||
}
|
||||
|
||||
/** One Backspace before every caret. */
|
||||
function backspace(view: EditorView) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: Math.max(0, r.head - 1), to: r.head },
|
||||
range: EditorSelection.cursor(Math.max(0, r.head - 1)),
|
||||
})),
|
||||
{ userEvent: "delete.backward" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("linked html tags", () => {
|
||||
it("renames the closing tag while the opening one is typed", () => {
|
||||
const v = make('<div| class="container">\n Content\n</div>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
// The caret stays where the user typed, not in the mirrored tag.
|
||||
expect(show(v)).toBe('<division| class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames the opening tag while the closing one is typed", () => {
|
||||
const v = make('<div class="container">\n Content\n</div|>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames a name typed at its start", () => {
|
||||
const v = make("<|div></div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<sdiv></sdiv>");
|
||||
});
|
||||
|
||||
it("follows deletions", () => {
|
||||
const v = make("<div|></div>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<di></di>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<d></d>");
|
||||
});
|
||||
|
||||
it("follows a selection replaced with a new name", () => {
|
||||
const v = make("<[div]>x</div>");
|
||||
type(v, "article");
|
||||
expect(v.state.doc.toString()).toBe("<article>x</article>");
|
||||
expect(show(v)).toBe("<article|>x</article>");
|
||||
});
|
||||
|
||||
it("pairs the outer tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div>inner</div>\n</div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs>\n <div>inner</div>\n</divs>");
|
||||
});
|
||||
|
||||
it("pairs the inner tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div|>inner</div>\n</div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<div>\n <divs>inner</divs>\n</div>");
|
||||
});
|
||||
|
||||
it("renames two elements with one caret each", () => {
|
||||
const v = make("<p|>one</p>\n<h2|>two</h2>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one</px>\n<h2x>two</h2x>");
|
||||
});
|
||||
|
||||
it("leaves both tags alone when the caret edits both at once", () => {
|
||||
const v = make("<div|></div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("mirrors only the caret inside a tag name", () => {
|
||||
const v = make("<p|>one\nplain| text\n</p>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one\nplainx text\n</px>");
|
||||
});
|
||||
|
||||
it("skips rather than guesses with two carets in one name", () => {
|
||||
const v = make("<di|v|></div>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<dixvx></div>");
|
||||
});
|
||||
|
||||
it("does not touch other tags or attributes", () => {
|
||||
const v = make('<div class="container" |>\n <span>keep</span>\n</div>');
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe('<div class="container" x>\n <span>keep</span>\n</div>');
|
||||
});
|
||||
|
||||
it("skips void and self-closing tags", () => {
|
||||
const v = make("<p>before</p>\n<img|>\n<br>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<p>before</p>\n<imgx>\n<br>");
|
||||
const selfClosing = make("<p>before</p>\n<div|/>");
|
||||
type(selfClosing, "x");
|
||||
expect(selfClosing.state.doc.toString()).toBe("<p>before</p>\n<divx/>");
|
||||
});
|
||||
|
||||
it("skips unclosed elements and stray close tags", () => {
|
||||
const v = make("<div|>unclosed");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx>unclosed");
|
||||
const stray = make("<p>before</p>\n</div|>");
|
||||
type(stray, "x");
|
||||
expect(stray.state.doc.toString()).toBe("<p>before</p>\n</divx>");
|
||||
});
|
||||
|
||||
it("keeps a partially typed tag unmirrored", () => {
|
||||
const v = make("<di|v");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<disv");
|
||||
});
|
||||
|
||||
it("does not follow structural characters typed at the name's edge", () => {
|
||||
// `/` would make the open tag self-closing; `>` would end it: neither extends the name.
|
||||
const v = make("<div|></div>");
|
||||
type(v, "/");
|
||||
expect(v.state.doc.toString()).toBe("<div/></div>");
|
||||
const closing = make("<div>content</di|v>");
|
||||
type(closing, ">");
|
||||
expect(closing.state.doc.toString()).toBe("<div>content</di>v>");
|
||||
});
|
||||
|
||||
it("undoes the pair as one step and redoes it", () => {
|
||||
const v = make("<div|></div>", history());
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
expect(undo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<div></div>");
|
||||
expect(redo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
});
|
||||
|
||||
// Every mode whose outer parser is `@lezer/html`: renaming an opening tag follows into
|
||||
// the closing one (prefs.ts enables the filter for exactly these language ids).
|
||||
describe("in the html-based modes", () => {
|
||||
const modes: [string, () => Extension][] = [
|
||||
["Vue", () => vueLanguage()],
|
||||
["Angular Template", () => angular()],
|
||||
["JSP", () => jsp()],
|
||||
["Django Template", () => django()],
|
||||
["Astro", () => astro()],
|
||||
];
|
||||
|
||||
for (const [name, language] of modes) {
|
||||
it(`renames the closing tag while the opening one is typed in ${name}`, () => {
|
||||
const v = makeWith([language(), linkedTags], '<div| class="a">x</div>');
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe('<divs class="a">x</divs>');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the smart key gates the filter", () => {
|
||||
/** The real wiring: prefsExtension attaches the filter for the document's language. */
|
||||
const prefs = (language: string | undefined, smartKeys: Record<string, unknown> = {}) =>
|
||||
makeWith(
|
||||
[
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
// A parseable tree: the real editors attach the mode's parser beside prefsExtension.
|
||||
html(),
|
||||
prefsExtension({
|
||||
prefs: validateEditorExtras({ smartKeys }),
|
||||
path: "",
|
||||
softWrap: false,
|
||||
showWhitespace: false,
|
||||
hardWrapAt: 120,
|
||||
readOnly: false,
|
||||
language,
|
||||
}),
|
||||
],
|
||||
"<div|></div>",
|
||||
);
|
||||
|
||||
it("links tags for plain html", () => {
|
||||
const v = prefs("html");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("links tags for the html-based language ids", () => {
|
||||
for (const language of ["vue", "jsp", "django-html", "astro"]) {
|
||||
const v = prefs(language);
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
}
|
||||
});
|
||||
|
||||
it("stays off when the option is off", () => {
|
||||
const v = prefs("html", { simultaneousTagEditing: false });
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
|
||||
it("stays off for the token-based erb mode", () => {
|
||||
const v = prefs("erb");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Linked editing for matching HTML tags, as in IDEA's Simultaneous
|
||||
* `<tag></tag>` editing (on by default) and VS Code's Linked Editing: editing
|
||||
* the name in an opening (or closing) tag renames the matching tag of the same
|
||||
* element as you type. Done from the syntax tree — `@lezer/html`'s
|
||||
* `OpenTag`/`CloseTag` elements, shared by every HTML-based mode — so it works
|
||||
* without a language server.
|
||||
*
|
||||
* A transaction whose change lies inside one tag's `TagName` is extended with
|
||||
* the same edit at the same offset in the counterpart's name — one
|
||||
* transaction, so undo takes both back together. Without a matching tag (void
|
||||
* and self-closing tags, unclosed elements, a stray close tag) or with the two
|
||||
* names already different (malformed markup) nothing follows: the tags stay as
|
||||
* they are.
|
||||
*/
|
||||
import { EditorState, type Extension, type Transaction } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { SyntaxNode, Tree } from "@lezer/common";
|
||||
import { isExternalChange } from "@/editor/api";
|
||||
|
||||
/** The `TagName` a changed range edits — an insertion at either edge extends the name. */
|
||||
function tagNameAround(tree: Tree, from: number, to: number): SyntaxNode | null {
|
||||
let n = tree.resolveInner(from, -1);
|
||||
if (n.name !== "TagName") n = tree.resolveInner(from, 1);
|
||||
return n.name === "TagName" && n.from <= from && to <= n.to ? n : null;
|
||||
}
|
||||
|
||||
/** The matching tag's name of the same element, or null without one. */
|
||||
function counterpartName(name: SyntaxNode): SyntaxNode | null {
|
||||
const tag = name.parent;
|
||||
const element = tag?.parent;
|
||||
if (!tag || element?.name !== "Element") return null;
|
||||
const other = element.getChild(
|
||||
/^Open/.test(tag.name) ? tag.name.replace("Open", "Close") : tag.name.replace("Close", "Open"),
|
||||
);
|
||||
return other?.getChild("TagName") ?? null;
|
||||
}
|
||||
|
||||
/** Insertions at a name's edge must extend the name; structural characters end it. */
|
||||
const NOT_NAME_CHARS = /[\s/>"'<=]/;
|
||||
|
||||
/**
|
||||
* Editing the name of an opening (or closing) HTML tag renames its matching
|
||||
* tag, from the syntax tree — no language server. Installed while Smart Keys ›
|
||||
* Simultaneous `<tag></tag>` editing is on (the default).
|
||||
*/
|
||||
export const linkedTags: Extension = EditorState.transactionFilter.of((tr: Transaction) => {
|
||||
const state = tr.startState;
|
||||
if (!tr.docChanged || state.readOnly || isExternalChange(tr)) return tr;
|
||||
const tree = syntaxTree(state);
|
||||
const edits: { from: number; to: number; insert: string }[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) =>
|
||||
edits.push({ from: fromA, to: toA, insert: inserted.toString() }),
|
||||
);
|
||||
const mirrored: { from: number; to: number; insert: string }[] = [];
|
||||
const targets = new Set<number>();
|
||||
for (const edit of edits) {
|
||||
const name = tagNameAround(tree, edit.from, edit.to);
|
||||
if (!name || NOT_NAME_CHARS.test(edit.insert)) continue;
|
||||
const other = counterpartName(name);
|
||||
// Names that already differ mean malformed markup: leave the tags alone.
|
||||
if (!other || state.sliceDoc(name.from, name.to) !== state.sliceDoc(other.from, other.to)) continue;
|
||||
// Both ends edited at once (a second caret, undo of a linked edit): already in step.
|
||||
if (edits.some((e) => e.from <= other.to && other.from <= e.to)) continue;
|
||||
// Two carets in one name: their offsets would not line up; skip rather than guess.
|
||||
if (targets.has(other.from)) return tr;
|
||||
targets.add(other.from);
|
||||
mirrored.push({
|
||||
from: other.from + (edit.from - name.from),
|
||||
to: other.from + (edit.to - name.from),
|
||||
insert: edit.insert,
|
||||
});
|
||||
}
|
||||
if (!mirrored.length) return tr;
|
||||
return [
|
||||
tr,
|
||||
{
|
||||
changes: mirrored
|
||||
.map((m) => ({ from: tr.changes.mapPos(m.from, -1), to: tr.changes.mapPos(m.to, 1), insert: m.insert }))
|
||||
.sort((a, b) => a.from - b.from),
|
||||
sequential: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./c
|
||||
import { enterInBlockComment, enterInDocComment } from "./docComment";
|
||||
import { renderDocComments } from "./renderedDocs";
|
||||
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { stringClipboard } from "./stringClipboard";
|
||||
import { filterCopiedText } from "./clipboard";
|
||||
import { LINE_INSET } from "./lineInset";
|
||||
@@ -480,6 +481,14 @@ export const GUTTER_ICON_GROUPS = [
|
||||
{ id: "colors", gutterClass: "cm-openidea-gutter-colors" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Languages whose outer parser is `@lezer/html`, where simultaneous
|
||||
* `<tag></tag>` editing works from the syntax tree. Angular templates carry
|
||||
* the `html` backend id (the Angular mode is picked per project), `erb` is a
|
||||
* token-based mode without a syntax tree and stays out.
|
||||
*/
|
||||
const LINKED_TAG_LANGUAGES = new Set(["html", "vue", "jsp", "django-html", "astro"]);
|
||||
|
||||
export interface PrefsOptions {
|
||||
prefs: EditorExtras;
|
||||
path: string;
|
||||
@@ -605,6 +614,11 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
|
||||
if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote));
|
||||
if (k.tabOutOfPair) out.push(tabOut);
|
||||
// Simultaneous `<tag></tag>` editing, from the syntax tree: every mode whose outer
|
||||
// parser is `@lezer/html` — plain HTML plus the HTML-based modes (Vue SFCs, Angular
|
||||
// templates, JSP, Django and Astro; Angular templates carry the `html` backend id).
|
||||
// ERB is token-based (no syntax tree), so it stays out.
|
||||
if (!o.readOnly && k.simultaneousTagEditing && LINKED_TAG_LANGUAGES.has(o.language ?? "")) out.push(linkedTags);
|
||||
if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle));
|
||||
|
||||
const keys = [];
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { restoreMaximizedToolWindow } from "@/workbench/toolwindows/store";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
documentDirty,
|
||||
documentLifecycle,
|
||||
failures,
|
||||
inputBusy,
|
||||
inputDirty,
|
||||
type CustomDocument,
|
||||
} from "./model/customDocuments";
|
||||
@@ -1226,27 +1227,40 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs of kinds with an `isBusy` answer are never evicted (that would end a
|
||||
* running process unasked): their answers are resolved first, so the sync
|
||||
* and the async ones are protected alike.
|
||||
*/
|
||||
private enforcePaneTabLimit(paneId: string, keep?: string, liveKeys?: ReadonlySet<string>) {
|
||||
const prefs = editorPrefs().tabs;
|
||||
const protect = (tab: Tab) =>
|
||||
!!liveKeys?.has(tab.input.key) || inputDirty(tab.input, useContributions.getState().editorKinds) === true;
|
||||
return this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
prefs.tabLimit,
|
||||
prefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, useContributions.getState().editorKinds) ??
|
||||
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
keep ?? current.panes[paneId]?.active ?? undefined,
|
||||
protect,
|
||||
)
|
||||
: current,
|
||||
{ protect },
|
||||
);
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const busyCapable = (useTabs.getState().panes[paneId]?.tabs ?? []).filter((t) => kinds.get(t.input.kind)?.isBusy);
|
||||
const evict = (busy: ReadonlySet<string>) => {
|
||||
const prefs = editorPrefs().tabs;
|
||||
// A tab whose kind reports a running process is never evicted: that would end it unasked.
|
||||
const protect = (tab: Tab) =>
|
||||
busy.has(tab.input.key) || inputDirty(tab.input, kinds) === true || !!liveKeys?.has(tab.input.key);
|
||||
return this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
prefs.tabLimit,
|
||||
prefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, kinds) ?? !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
keep ?? current.panes[paneId]?.active ?? undefined,
|
||||
protect,
|
||||
)
|
||||
: current,
|
||||
{ protect },
|
||||
);
|
||||
};
|
||||
if (!busyCapable.length) return evict(new Set());
|
||||
return Promise.all(
|
||||
busyCapable.map(async (tab) => ((await inputBusy(tab.input, kinds)) ? tab.input.key : null)),
|
||||
).then((keys) => evict(new Set(keys.filter((key): key is string => key !== null))));
|
||||
}
|
||||
|
||||
activate(paneId: string, tabId: string, focus = true) {
|
||||
@@ -1316,6 +1330,30 @@ export class EditorController implements SlotHost {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs about to lose their last tab whose kind reports them busy
|
||||
* (`EditorKindContribution.isBusy`): asks before ending what runs in them,
|
||||
* like closing a busy terminal session. Resolves false to keep the tabs.
|
||||
*/
|
||||
private async confirmBusyClose(removal: (s: TabsState) => TabsState): Promise<boolean> {
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const current = useTabs.getState();
|
||||
const remaining = openKeys(removal(current));
|
||||
const closing = new Map<string, EditorInput>();
|
||||
for (const pane of Object.values(current.panes))
|
||||
for (const tab of pane.tabs)
|
||||
if (!remaining.has(tab.input.key) && kinds.get(tab.input.kind)?.isBusy) closing.set(tab.input.key, tab.input);
|
||||
const busy: string[] = [];
|
||||
for (const input of closing.values()) if (await inputBusy(input, kinds)) busy.push(tabTitle(input, kinds));
|
||||
if (!busy.length) return true;
|
||||
const name = busy.join(", ");
|
||||
return confirm({
|
||||
title: t("editor.closeBusy.title", { name }),
|
||||
message: t("editor.closeBusy.message", { name }),
|
||||
ok: t("editor.closeBusy.terminate"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Close exactly the originally selected tabs, evaluating references after every await. */
|
||||
private removeGuarded(
|
||||
select: (s: TabsState) => TabsState,
|
||||
@@ -1389,6 +1427,8 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
authorized.add(d.input.key);
|
||||
}
|
||||
if (!(await this.confirmBusyClose(currentRemoval))) return false;
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
if (options.beforeRemove && !(await options.beforeRemove())) return false;
|
||||
// Commit only the original target ids against the latest tab state.
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
|
||||
interface SourceState {
|
||||
@@ -82,6 +82,9 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project !== previous.project) clear();
|
||||
});
|
||||
// A reload may have replaced a library jar in place (a SNAPSHOT installed again): reopened
|
||||
// classes fetch their contents again (the backend refreshes the open ones).
|
||||
const offRoots = onIdeEvent("projectRootsChanged", () => invalidateVirtualDocuments());
|
||||
const offActions = registerActions([
|
||||
{
|
||||
id: "editor.downloadSources",
|
||||
@@ -118,6 +121,7 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
return () => {
|
||||
offActions();
|
||||
offProject();
|
||||
offRoots();
|
||||
clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,19 @@ export function inputDirty(input: EditorInput, kinds: Kinds): boolean | null {
|
||||
return lifecycle ? documentDirty({ input, lifecycle }) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the input's editor kind reports it busy (`EditorKindContribution.isBusy`).
|
||||
* A failing check counts as not busy, so a broken kind cannot make its tabs unclosable.
|
||||
*/
|
||||
export async function inputBusy(input: EditorInput, kinds: Kinds): Promise<boolean> {
|
||||
try {
|
||||
return (await kinds.get(input.kind)?.isBusy?.(input)) === true;
|
||||
} catch (e) {
|
||||
console.warn(`editor input ${input.key}: isBusy failed`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The rejection reasons of `results` (an AggregateError contributes each of its errors). */
|
||||
export function failures(results: readonly PromiseSettledResult<unknown>[]): unknown[] {
|
||||
return results.flatMap((r) =>
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,58 +22,62 @@
|
||||
inset 0 0 0 1px var(--focus-ring),
|
||||
0 0 0 1px var(--focus-ring);
|
||||
}
|
||||
/* The CodeMirror text field: the UI font, wrapping, growing with the text up to a limit. */
|
||||
.field {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Tags under the transparent textarea: same font, metrics and padding, so the caret
|
||||
and selection of the real textarea line up with the painted text. */
|
||||
.mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px 2px;
|
||||
font: inherit;
|
||||
.field :global(.cm-editor) {
|
||||
background: transparent;
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.field :global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
.field :global(.cm-scroller) {
|
||||
min-height: 44px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field :global(.cm-content) {
|
||||
padding: 10px 12px 2px;
|
||||
caret-color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--fg-strong);
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.textarea {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: var(--fg-strong);
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px 2px;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
.field :global(.cm-line) {
|
||||
padding: 0;
|
||||
}
|
||||
.textarea:focus-visible {
|
||||
outline: none;
|
||||
/* A wide card (a ``` block) takes the text's width, its code scrolling inside, rather than
|
||||
widening the text past the box. */
|
||||
.field :global(.cm-content) {
|
||||
min-width: 0;
|
||||
}
|
||||
.textarea::placeholder {
|
||||
.field :global(.cm-placeholder) {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
/* The textarea's own glyphs are transparent so the mirror above shows the tags; the
|
||||
selection would then paint an empty highlight. Paint the selected glyphs here instead —
|
||||
the opaque selection background covers the mirror underneath. */
|
||||
.textarea::selection {
|
||||
.field :global(.cm-cursor) {
|
||||
border-left-color: var(--fg-strong);
|
||||
}
|
||||
.field ::selection {
|
||||
background: var(--bg-editor-selection);
|
||||
}
|
||||
/* A dragged file over the box: what dropping does. */
|
||||
.dropOverlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--bg-popup) 82%, transparent);
|
||||
color: var(--fg-strong);
|
||||
pointer-events: none;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
@@ -347,17 +351,19 @@
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.images {
|
||||
/* Above the text: the current file to reference, attached files and images. */
|
||||
.context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
cursor: default;
|
||||
}
|
||||
.image {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
overflow: hidden;
|
||||
@@ -393,21 +399,63 @@
|
||||
opacity: 0;
|
||||
}
|
||||
.image:hover .removeImage,
|
||||
.attachment:hover .removeImage,
|
||||
.removeImage:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
/* Attached files and the current-file suggestion: one height, like the chips in the text. */
|
||||
.attachment,
|
||||
.suggestion {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
max-width: 240px;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-small);
|
||||
height: 24px;
|
||||
padding: 0 4px 0 7px;
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.suggestion {
|
||||
padding-right: 8px;
|
||||
border: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
outline: 1px dashed var(--border-strong);
|
||||
outline-offset: -1px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease),
|
||||
background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.suggestion:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
}
|
||||
.suggestionHint {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.removeChip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.removeChip:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.attachmentName {
|
||||
overflow: hidden;
|
||||
@@ -416,16 +464,6 @@
|
||||
}
|
||||
|
||||
/* `@` / `/` completion; Popup keeps the list within the viewport. */
|
||||
.caretMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -453,3 +491,30 @@
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.match {
|
||||
background: none;
|
||||
color: var(--fg-completion-match, var(--accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
padding: 5px 12px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.popupFooter kbd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 2px;
|
||||
padding: 0 3px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-keys, inherit);
|
||||
font-size: var(--fs-tiny, 11px);
|
||||
}
|
||||
+669
-205
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,8 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
/* The chips' own padding spaces them. */
|
||||
gap: 2px;
|
||||
padding: 0 2px;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
@@ -67,7 +68,6 @@
|
||||
.docked .where {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.docked .projectPicker,
|
||||
.docked .projectChip {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { iconNode } from "@/editor/lsp/iconMarkup";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { copyText } from "@/ui";
|
||||
@@ -20,6 +19,7 @@ import { hasMermaid, renderMermaidIn } from "@/features/viewers/markdown/mermaid
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { fileHref, pathLink, type PathLink } from "./conversation";
|
||||
import { loadImages, type MessageImages } from "./markdownImages";
|
||||
import { fileExists } from "./ReferenceChip";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
const escapeHtml = (s: string) =>
|
||||
@@ -167,20 +167,6 @@ function wrapLooseText(li: Element) {
|
||||
flush();
|
||||
}
|
||||
|
||||
/** Whether a path names an existing file, per path (agents name the same files again and again). */
|
||||
const existing = new Map<string, Promise<boolean>>();
|
||||
function fileExists(path: string): Promise<boolean> {
|
||||
let known = existing.get(path);
|
||||
if (!known) {
|
||||
known = call(commands.fsProbe(path)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
existing.set(path, known);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
export function openPathLink(link: PathLink) {
|
||||
const selection = link.line === undefined ? undefined : { line: link.line, col: link.col ?? 0 };
|
||||
void editorService().openFile(link.path, { selection, focus: true });
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/* An `@file` / `/skill` reference: icon, name and line range, in the composer and in sent messages. */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
height: 20px;
|
||||
padding: 0 6px 0 4px;
|
||||
margin: 0 1px;
|
||||
vertical-align: -4px;
|
||||
border-radius: var(--radius-selection);
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 14%, transparent);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
.chip > svg,
|
||||
.chip > img {
|
||||
flex: none;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lines {
|
||||
flex: none;
|
||||
color: var(--fg-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The file is gone (renamed or deleted since it was referenced). */
|
||||
.missing {
|
||||
color: var(--fg-muted);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
outline: 1px dashed var(--border-strong);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.missing .name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* Selected together with the text around it in the composer. */
|
||||
.selected {
|
||||
background: var(--bg-editor-selection);
|
||||
}
|
||||
/* A `/command` or skill. */
|
||||
.command {
|
||||
display: inline;
|
||||
padding: 1px 3px;
|
||||
margin: 0 -3px;
|
||||
border-radius: var(--radius-selection);
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
/* `@something` that names no referenced file: as typed, just marked. */
|
||||
.pending {
|
||||
color: var(--fg-strong);
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--fg) 40%, transparent);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* A quoted reply or selection in a sent message: as a Markdown quote in a reply. */
|
||||
.quote {
|
||||
margin: 2px 0 6px;
|
||||
padding: 0 10px;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* Hover card: the full path, and the referenced lines (or the file's first lines). */
|
||||
.card {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(520px, calc(100vw - 24px));
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-popup);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-popup),
|
||||
var(--shadow-popup);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-small);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.cardPath {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.cardMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.cardCode {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
color: var(--fg-editor);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
tab-size: 4;
|
||||
}
|
||||
.cardLine {
|
||||
flex: none;
|
||||
min-width: 3ch;
|
||||
margin-right: 10px;
|
||||
color: var(--fg-line-number);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* A quote in the composer: its lines as one block, the `>` hidden. */
|
||||
.quoteLine {
|
||||
margin-left: 0;
|
||||
padding-left: 10px !important;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.quoteFirst {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.quoteLast {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Sent message text: lays out as the text it wraps. */
|
||||
.text {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* A ``` block (pasted code, terminal output): a card with a title, the line count and the code. */
|
||||
.codeCard {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 4px 0 6px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.codeHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.codeTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codeMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.codeRemove,
|
||||
.codeCopy {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeRemove:hover,
|
||||
.codeCopy:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.codeBody {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
color: var(--fg-editor);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
/* More lines than shown: the last ones fade out. */
|
||||
.codeFaded {
|
||||
-webkit-mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
|
||||
mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
|
||||
}
|
||||
.codeMore {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeMore:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* In the composer: the card opens as text on a click. */
|
||||
.codeCard[title] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.codeCard[title]:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
}
|
||||
/* A ``` block being edited as text in the composer. */
|
||||
.codeLine {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
background: var(--bg-editor-inline, var(--bg-elev));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* `@file` / `@folder/` / `@file:10-20` references drawn as chips (icon, name, lines), in the
|
||||
* composer and in sent messages: a click opens the file at its lines (a folder is selected in
|
||||
* the Project view), hovering shows the full path and the lines.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/react-dom";
|
||||
import { highlightInto, languageForPath } from "@/editor/lsp/markdown";
|
||||
import { iconNode } from "@/editor/lsp/iconMarkup";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { basename } from "@/platform/project";
|
||||
import { copyText, fileIconName, Icon } from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import {
|
||||
fenceLabel,
|
||||
fencedBlocks,
|
||||
isQuoteLine,
|
||||
linesLabel,
|
||||
messageTokens,
|
||||
parseMention,
|
||||
resolveTyped,
|
||||
type Mention,
|
||||
} from "./composerModel";
|
||||
import styles from "./ReferenceChip.module.css";
|
||||
|
||||
export { styles as chipStyles };
|
||||
|
||||
/** Whether a path names an existing file or folder, per path (the same files come up again and again). */
|
||||
const existing = new Map<string, Promise<boolean>>();
|
||||
export function fileExists(path: string): Promise<boolean> {
|
||||
let known = existing.get(path);
|
||||
if (!known) {
|
||||
known = call(commands.fsProbe(path)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
existing.set(path, known);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
/** Opens a referenced file at its first line, or selects a folder in the Project view. */
|
||||
export function openReference(path: string, mention: Mention) {
|
||||
if (mention.dir) {
|
||||
void executeAction("navigate.selectInProjectView", path.replace(/\/$/, ""));
|
||||
return;
|
||||
}
|
||||
const selection = mention.lines ? { line: mention.lines.from - 1, col: 0 } : undefined;
|
||||
void editorService().openFile(path, { selection, focus: true });
|
||||
}
|
||||
|
||||
const nameOf = (m: Mention) => basename(m.rel.replace(/\/$/, "")) || m.rel;
|
||||
const iconOf = (m: Mention) => (m.dir ? "folder" : fileIconName(nameOf(m), m.rel));
|
||||
|
||||
/** The chip as DOM, for the composer's editor. */
|
||||
export function chipElement(path: string, mention: Mention): HTMLElement {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = styles.chip!;
|
||||
chip.dataset.refPath = path;
|
||||
chip.dataset.refRel = mention.rel;
|
||||
if (mention.lines) chip.dataset.refLines = linesLabel(mention.lines);
|
||||
chip.setAttribute("aria-label", ariaLabel(mention));
|
||||
chip.append(iconNode(iconOf(mention), 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.name!;
|
||||
name.textContent = nameOf(mention);
|
||||
chip.append(name);
|
||||
if (mention.lines) {
|
||||
const lines = document.createElement("span");
|
||||
lines.className = styles.lines!;
|
||||
lines.textContent = linesLabel(mention.lines);
|
||||
chip.append(lines);
|
||||
}
|
||||
if (!mention.dir)
|
||||
void fileExists(path).then((exists) => {
|
||||
if (!exists) chip.classList.add(styles.missing!);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function ariaLabel(m: Mention): string {
|
||||
const name = nameOf(m);
|
||||
if (m.dir) return tx`Folder reference ${name}`;
|
||||
if (!m.lines) return tx`File reference ${name}`;
|
||||
const lines = linesLabel(m.lines);
|
||||
return tx`File reference ${name}, lines ${lines}`;
|
||||
}
|
||||
|
||||
/** A reference in a sent message. */
|
||||
export function ReferenceChip({ path, mention }: { path: string; mention: Mention }) {
|
||||
const [missing, setMissing] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!mention.dir) void fileExists(path).then((exists) => alive && setMissing(!exists));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [path, mention.dir]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.chip, missing && styles.missing)}
|
||||
data-ref-path={path}
|
||||
data-ref-rel={mention.rel}
|
||||
data-ref-lines={mention.lines ? linesLabel(mention.lines) : undefined}
|
||||
aria-label={ariaLabel(mention)}
|
||||
onClick={() => openReference(path, mention)}
|
||||
>
|
||||
<Icon name={iconOf(mention)} size={14} />
|
||||
<span className={styles.name}>{nameOf(mention)}</span>
|
||||
{mention.lines && <span className={styles.lines}>{linesLabel(mention.lines)}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message text with its references as chips, `/commands` tagged, `>` quotes and ``` blocks as cards.
|
||||
* `cwd` is the directory references resolve in.
|
||||
*/
|
||||
export function ReferencedText({ text, cwd }: { text: string; cwd: string }) {
|
||||
const [root, setRoot] = useState<HTMLSpanElement | null>(null);
|
||||
useReferenceHover(root);
|
||||
const all = text.split("\n");
|
||||
const blocks: ({ kind: "quote" | "text"; text: string } | { kind: "code"; info: string; text: string })[] = [];
|
||||
const push = (kind: "quote" | "text", body: string) => {
|
||||
const last = blocks.at(-1);
|
||||
if (last && last.kind === kind) last.text += `\n${body}`;
|
||||
else blocks.push({ kind, text: body });
|
||||
};
|
||||
let i = 0;
|
||||
for (const fence of [...fencedBlocks(all), null]) {
|
||||
const end = fence ? fence.open : all.length;
|
||||
for (; i < end; i++) {
|
||||
const line = all[i]!;
|
||||
const quote = isQuoteLine(line);
|
||||
push(quote ? "quote" : "text", quote ? line.replace(/^> ?/, "") : line);
|
||||
}
|
||||
if (!fence) break;
|
||||
blocks.push({ kind: "code", info: fence.info, text: all.slice(fence.open + 1, fence.close).join("\n") });
|
||||
i = fence.close + 1;
|
||||
}
|
||||
return (
|
||||
<span ref={setRoot} className={styles.text}>
|
||||
{blocks.map((block, n) => {
|
||||
if (block.kind === "code") return <CodeCard key={n} info={block.info} body={block.text} />;
|
||||
if (block.kind === "quote")
|
||||
return (
|
||||
<div key={n} className={styles.quote}>
|
||||
{block.text}
|
||||
</div>
|
||||
);
|
||||
// A block ends its own line: the paragraph after it starts without an empty one.
|
||||
const body = n > 0 ? block.text.replace(/^\n/, "") : block.text;
|
||||
return <Fragment key={n}>{inline(body, cwd)}</Fragment>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function inline(text: string, cwd: string): ReactNode[] {
|
||||
return messageTokens(text).map((token, i) => {
|
||||
if (token.kind === "text") return <Fragment key={i}>{token.value}</Fragment>;
|
||||
if (token.kind === "command")
|
||||
return (
|
||||
<span key={i} className={styles.command}>
|
||||
{token.value}
|
||||
</span>
|
||||
);
|
||||
const mention = parseMention(token.value);
|
||||
if (!mention) return <Fragment key={i}>{token.value}</Fragment>;
|
||||
return <ReferenceChip key={i} path={resolveTyped(cwd, mention.rel)} mention={mention} />;
|
||||
});
|
||||
}
|
||||
|
||||
const HOVER_DELAY = 400;
|
||||
const PREVIEW_LINES = 12;
|
||||
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Hover cards for the chips below `root`: the full path, and the referenced lines (a file's
|
||||
* first lines without a range). Returns a disposer.
|
||||
*/
|
||||
export function referenceHover(root: HTMLElement): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let card: HTMLElement | null = null;
|
||||
let current: HTMLElement | null = null;
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
card?.remove();
|
||||
card = null;
|
||||
current = null;
|
||||
};
|
||||
|
||||
const place = (anchor: HTMLElement, el: HTMLElement) =>
|
||||
void computePosition(anchor, el, {
|
||||
strategy: "fixed",
|
||||
placement: "top-start",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
||||
});
|
||||
|
||||
const show = (chip: HTMLElement) => {
|
||||
const path = chip.dataset.refPath!;
|
||||
const rel = chip.dataset.refRel!;
|
||||
const mention = parseMention(
|
||||
`@${rel}${chip.dataset.refLines ? `:${chip.dataset.refLines.replace("–", "-")}` : ""}`,
|
||||
);
|
||||
if (!mention) return;
|
||||
const el = document.createElement("div");
|
||||
el.className = styles.card!;
|
||||
el.setAttribute("role", "tooltip");
|
||||
const head = document.createElement("div");
|
||||
head.className = styles.cardHead!;
|
||||
head.append(iconNode(iconOf(mention), 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.cardPath!;
|
||||
name.textContent = rel.replace(/\/$/, "");
|
||||
head.append(name);
|
||||
const meta = document.createElement("span");
|
||||
meta.className = styles.cardMeta!;
|
||||
const lines = mention.lines && linesLabel(mention.lines);
|
||||
meta.textContent = chip.classList.contains(styles.missing!)
|
||||
? tx("File not found")
|
||||
: lines
|
||||
? tx`Lines ${lines}`
|
||||
: mention.dir
|
||||
? tx("Folder")
|
||||
: "";
|
||||
head.append(meta);
|
||||
el.append(head);
|
||||
document.body.append(el);
|
||||
card = el;
|
||||
place(chip, el);
|
||||
if (mention.dir || chip.classList.contains(styles.missing!)) return;
|
||||
void preview(path, mention).then((code) => {
|
||||
if (card !== el || !code) return;
|
||||
el.append(code);
|
||||
place(chip, el);
|
||||
});
|
||||
};
|
||||
|
||||
const over = (e: PointerEvent) => {
|
||||
const chip = (e.target as Element | null)?.closest?.<HTMLElement>("[data-ref-path]") ?? null;
|
||||
if (chip === current) return;
|
||||
hide();
|
||||
if (!chip || !root.contains(chip)) return;
|
||||
current = chip;
|
||||
timer = setTimeout(() => show(chip), HOVER_DELAY);
|
||||
};
|
||||
|
||||
root.addEventListener("pointerover", over);
|
||||
root.addEventListener("pointerleave", hide);
|
||||
root.addEventListener("pointerdown", hide, true);
|
||||
root.addEventListener("keydown", hide, true);
|
||||
root.addEventListener("scroll", hide, true);
|
||||
return () => {
|
||||
hide();
|
||||
root.removeEventListener("pointerover", over);
|
||||
root.removeEventListener("pointerleave", hide);
|
||||
root.removeEventListener("pointerdown", hide, true);
|
||||
root.removeEventListener("keydown", hide, true);
|
||||
root.removeEventListener("scroll", hide, true);
|
||||
};
|
||||
}
|
||||
|
||||
async function preview(path: string, mention: Mention): Promise<HTMLElement | null> {
|
||||
try {
|
||||
const probe = await call(commands.fsProbe(path));
|
||||
if (probe.binary || probe.size > MAX_PREVIEW_BYTES) return null;
|
||||
const all = (await call(commands.fsReadText(path))).split("\n");
|
||||
const first = mention.lines ? Math.min(mention.lines.from, all.length) : 1;
|
||||
const last = Math.min(mention.lines ? mention.lines.to : PREVIEW_LINES, first + PREVIEW_LINES - 1, all.length);
|
||||
const lines = all.slice(first - 1, last).map((l) => l.replace(/\r$/, "").replace(/\t/g, " "));
|
||||
while (lines.length && !lines.at(-1)!.trim()) lines.pop();
|
||||
if (!lines.length) return null;
|
||||
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length));
|
||||
const text = lines.map((l) => l.slice(indent)).join("\n");
|
||||
const code = document.createElement("pre");
|
||||
code.className = styles.cardCode!;
|
||||
const gutter = document.createElement("span");
|
||||
gutter.className = styles.cardLine!;
|
||||
gutter.textContent = lines.map((_, i) => first + i).join("\n");
|
||||
const body = document.createElement("code");
|
||||
body.textContent = text;
|
||||
code.append(gutter, body);
|
||||
await highlightInto(body, text, languageForPath(path));
|
||||
return code;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function useReferenceHover(root: HTMLElement | null) {
|
||||
const t = useTx();
|
||||
useEffect(() => (root ? referenceHover(root) : undefined), [root, t]);
|
||||
}
|
||||
|
||||
const KIND_TITLES = { terminal: "Terminal", problems: "Problems", trace: "Stack Trace", text: "Pasted Text" } as const;
|
||||
const KIND_ICONS = { terminal: "terminal", problems: "problems", trace: "exception", text: "text" } as const;
|
||||
|
||||
/** A ``` block's header: icon and title ("Terminal · zsh", "TypeScript"). */
|
||||
function cardTitle(info: string): { icon: string; title: string } {
|
||||
const { kind, label, language } = fenceLabel(info);
|
||||
if (kind !== "code") {
|
||||
const title = tx(KIND_TITLES[kind]);
|
||||
return { icon: KIND_ICONS[kind], title: label ? `${title} · ${label}` : title };
|
||||
}
|
||||
const name = language && label === language ? languageForPath(`x.${language}`) : null;
|
||||
return { icon: language ? fileIconName(`x.${language}`) : "file", title: name ?? (label || tx("Code")) };
|
||||
}
|
||||
|
||||
const lineCount = (count: number, t: typeof tx) => (count === 1 ? t("1 line") : t`${count} lines`);
|
||||
|
||||
const COMPOSER_PREVIEW_LINES = 5;
|
||||
|
||||
/**
|
||||
* A ``` block in the composer, folded to a card: its title, line count and first lines.
|
||||
* `onOpen` edits it as text; `onRemove` deletes it.
|
||||
*/
|
||||
export function codeCardElement(info: string, body: string, onOpen: () => void, onRemove: () => void): HTMLElement {
|
||||
const { icon, title } = cardTitle(info);
|
||||
const lines = body.split("\n");
|
||||
const card = document.createElement("div");
|
||||
card.className = styles.codeCard!;
|
||||
card.setAttribute("role", "group");
|
||||
card.setAttribute("aria-label", title);
|
||||
const head = document.createElement("div");
|
||||
head.className = styles.codeHead!;
|
||||
head.append(iconNode(icon, 14));
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.codeTitle!;
|
||||
name.textContent = title;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = styles.codeMeta!;
|
||||
meta.textContent = lineCount(lines.length, tx);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = styles.codeRemove!;
|
||||
remove.setAttribute("aria-label", tx("Remove"));
|
||||
remove.append(iconNode("close", 10));
|
||||
remove.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
remove.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
});
|
||||
head.append(name, meta, remove);
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = clsx(styles.codeBody, lines.length > COMPOSER_PREVIEW_LINES && styles.codeFaded);
|
||||
const shown = lines.slice(0, COMPOSER_PREVIEW_LINES).join("\n");
|
||||
pre.textContent = shown;
|
||||
const { language, kind } = fenceLabel(info);
|
||||
if (kind === "code") void highlightInto(pre, shown, language);
|
||||
card.append(head, pre);
|
||||
card.title = tx("Click to edit");
|
||||
card.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
card.addEventListener("click", onOpen);
|
||||
return card;
|
||||
}
|
||||
|
||||
const MESSAGE_PREVIEW_LINES = 12;
|
||||
|
||||
/** A ``` block in a sent message: a card with its title, the code highlighted, long ones folded. */
|
||||
function CodeCard({ info, body }: { info: string; body: string }) {
|
||||
const t = useTx();
|
||||
const { icon, title } = cardTitle(info);
|
||||
const lines = body.split("\n");
|
||||
const [open, setOpen] = useState(lines.length <= MESSAGE_PREVIEW_LINES);
|
||||
const code = useRef<HTMLPreElement>(null);
|
||||
const shown = open ? body : lines.slice(0, MESSAGE_PREVIEW_LINES).join("\n");
|
||||
useEffect(() => {
|
||||
const el = code.current;
|
||||
if (!el) return;
|
||||
el.textContent = shown;
|
||||
const { language, kind } = fenceLabel(info);
|
||||
if (kind === "code") void highlightInto(el, shown, language);
|
||||
}, [shown, info]);
|
||||
const more = lines.length - MESSAGE_PREVIEW_LINES;
|
||||
return (
|
||||
<div className={styles.codeCard} role="group" aria-label={title}>
|
||||
<div className={styles.codeHead}>
|
||||
<Icon name={icon} size={14} />
|
||||
<span className={styles.codeTitle}>{title}</span>
|
||||
<span className={styles.codeMeta}>{lineCount(lines.length, t)}</span>
|
||||
<button type="button" className={styles.codeCopy} aria-label={t("Copy")} onClick={() => void copyText(body)}>
|
||||
<Icon name="copy" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<pre ref={code} className={clsx(styles.codeBody, !open && styles.codeFaded)} />
|
||||
{more > 0 && (
|
||||
<button type="button" className={styles.codeMore} onClick={() => setOpen(!open)}>
|
||||
{open ? t("Show less") : t`Show ${more} more lines`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,11 +50,13 @@
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
/* The facts on the bar's line are as tall as its chips, so their text shares one centre line. */
|
||||
.agent {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
min-width: 0;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
@@ -69,7 +71,8 @@
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
/* The chips' own padding spaces them. */
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -78,7 +81,10 @@
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.state {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The changes sit above the meter, time and terminal; those keep their one row at the right. */
|
||||
@@ -96,6 +102,7 @@
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
@@ -134,8 +141,8 @@
|
||||
}
|
||||
.statusChip:hover,
|
||||
.statusChip[aria-expanded="true"] {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
background: var(--bg-toolbar-hover);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
/* In a narrow docked session the chip is narrower than its text: the label ends in an ellipsis. */
|
||||
.statusChipLabel {
|
||||
@@ -310,6 +317,14 @@
|
||||
--session-card-width: 96%;
|
||||
}
|
||||
}
|
||||
/* A narrow docked session: rather than cut every fact to a letter or two, the bar keeps the
|
||||
agent's icon and its state; where it works is in the task's tooltip and Changes. */
|
||||
@container (max-width: 420px) {
|
||||
.where,
|
||||
.agentName {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InlineTag,
|
||||
Radio,
|
||||
SearchField,
|
||||
Spinner,
|
||||
@@ -64,6 +63,7 @@ import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/featur
|
||||
import { repositoryPath } from "@/features/vcs/repositoryPaths";
|
||||
import { setChangesOptions, useChangesOptions } from "./changesOptions";
|
||||
import { Composer } from "./Composer";
|
||||
import { ReferencedText } from "./ReferenceChip";
|
||||
import { selectionIn } from "./selection";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import {
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
emptyDraft,
|
||||
limitLabel,
|
||||
messageTokens,
|
||||
parseMention,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
workTime,
|
||||
@@ -351,7 +352,7 @@ function SessionStatus({
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<span className={styles.agent} title={task.cwd}>
|
||||
<span className={styles.agent} title={`${providerName(task.provider)} · ${task.cwd}`}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.agentName}>
|
||||
{providerName(task.provider)}
|
||||
@@ -920,7 +921,10 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
const laidOut =
|
||||
el.scrollHeight !== seenHeight.current.content || el.clientHeight !== seenHeight.current.view;
|
||||
seenHeight.current = { content: el.scrollHeight, view: el.clientHeight };
|
||||
seenHeight.current = {
|
||||
content: el.scrollHeight,
|
||||
view: el.clientHeight,
|
||||
};
|
||||
const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut;
|
||||
const movedDown = el.scrollTop > position.current.top;
|
||||
const stayedAtEnd = position.current.stick && el.scrollTop === position.current.top;
|
||||
@@ -1009,23 +1013,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
|
||||
type UserEntry = Extract<AgentEntry, { type: "user" }>;
|
||||
|
||||
/** Message text with file references and skills as tags, distinct from prose. */
|
||||
function MessageText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{messageTokens(text).map((token, i) =>
|
||||
token.kind === "text" ? (
|
||||
<Fragment key={i}>{token.value}</Fragment>
|
||||
) : (
|
||||
<InlineTag key={i} accent={token.kind === "command"}>
|
||||
{token.value}
|
||||
</InlineTag>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** When a message was sent, or when the turn behind a reply finished, and how long it took. */
|
||||
function MessageTime({
|
||||
sentMs,
|
||||
@@ -1430,7 +1417,7 @@ const Entry = memo(function Entry({
|
||||
<T>Task from the parent agent</T>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ReferencedText text={entry.text} cwd={task.cwd} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1494,7 +1481,14 @@ function UserMessage({
|
||||
}
|
||||
const images = entry.images ?? [];
|
||||
// Images are listed after the files in `attachments` and shown as thumbnails instead.
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
|
||||
// Files mentioned in the text are chips there already.
|
||||
const mentioned = new Set(
|
||||
messageTokens(entry.text).flatMap((t) => {
|
||||
const m = t.kind === "file" ? parseMention(t.value) : null;
|
||||
return m ? [basename(m.rel.replace(/\/$/, ""))] : [];
|
||||
}),
|
||||
);
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length).filter((a) => !mentioned.has(a));
|
||||
return (
|
||||
<div className={clsx(styles.message, styles.userMessage)}>
|
||||
{/* Actions and the time sit beside the author on hover: the reply under it has the turn's duration. */}
|
||||
@@ -1541,7 +1535,7 @@ function UserMessage({
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ReferencedText text={entry.text} cwd={task.cwd} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -2673,7 +2667,7 @@ function PendingPermission({ task, entry }: { task: AgentTask; entry: Permission
|
||||
if (!el?.contains(document.activeElement) && document.activeElement !== document.body) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement === document.body || !document.activeElement?.isConnected)
|
||||
area?.querySelector<HTMLElement>("textarea")?.focus({ preventScroll: true });
|
||||
area?.querySelector<HTMLElement>(".cm-content")?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
@@ -2952,7 +2946,12 @@ function changeRows(
|
||||
commits: readonly AgentCommit[],
|
||||
grouped: boolean,
|
||||
): ChangeRow[] {
|
||||
if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change }));
|
||||
if (!grouped)
|
||||
return uncommitted.map((change) => ({
|
||||
key: change.path,
|
||||
type: "file" as const,
|
||||
change,
|
||||
}));
|
||||
const roots: ChangeRow[] = [];
|
||||
if (uncommitted.length) {
|
||||
roots.push({
|
||||
@@ -2961,7 +2960,11 @@ function changeRows(
|
||||
label: "Uncommitted",
|
||||
count: uncommitted.length,
|
||||
...statsOf(uncommitted),
|
||||
children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })),
|
||||
children: uncommitted.map((change) => ({
|
||||
key: `uncommitted:${change.path}`,
|
||||
type: "file",
|
||||
change,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (commits.length) {
|
||||
@@ -3006,7 +3009,10 @@ function ChangesPane({
|
||||
onHide: () => void;
|
||||
}) {
|
||||
// The two views key their rows differently: a selection doesn't carry over.
|
||||
const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet<string> }>(() => ({
|
||||
const [selection, setSelection] = useState<{
|
||||
grouped: boolean;
|
||||
keys: ReadonlySet<string>;
|
||||
}>(() => ({
|
||||
grouped,
|
||||
keys: NO_SELECTION,
|
||||
}));
|
||||
@@ -3110,7 +3116,10 @@ function ChangesPane({
|
||||
body: tx`You changed them yourself, accept them by hand: ${skippedNames}.`,
|
||||
});
|
||||
} else {
|
||||
notifications().notify({ level: "info", title: tx("Changes accepted") });
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: tx("Changes accepted"),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
@@ -3307,7 +3316,10 @@ function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
|
||||
const right: DiffData["right"] =
|
||||
change.kind === "deleted"
|
||||
? { type: "text", text: "", label: "(deleted)", absent: true }
|
||||
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
|
||||
: {
|
||||
type: "worktree",
|
||||
label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree",
|
||||
};
|
||||
return { path: change.path, left, right };
|
||||
}
|
||||
|
||||
@@ -3336,7 +3348,12 @@ async function openUncommittedDiff(task: AgentTask, path: string) {
|
||||
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
|
||||
const diffs = commit.files.map((file) =>
|
||||
commitFileDiffData(
|
||||
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
|
||||
{
|
||||
id: commit.id,
|
||||
shortId: commit.shortId,
|
||||
parents: commit.parent ? [commit.parent] : [],
|
||||
repo: task.cwd,
|
||||
},
|
||||
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
recall,
|
||||
toPrompt,
|
||||
mergeMatches,
|
||||
messageTokens,
|
||||
fenced,
|
||||
fencedBlocks,
|
||||
fenceLabel,
|
||||
pastedPaths,
|
||||
looksLikeTrace,
|
||||
placesInText,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
limitLabel,
|
||||
@@ -72,6 +79,53 @@ const option = (category: string, values: string[], current: string | null): Age
|
||||
via: "option",
|
||||
});
|
||||
|
||||
describe("fenced blocks", () => {
|
||||
it("finds closed blocks, a longer fence holding backticks", () => {
|
||||
const text = fenced("a ``` b\n```", "terminal zsh");
|
||||
expect(text).toBe("````terminal zsh\na ``` b\n```\n````");
|
||||
expect(fencedBlocks(["x", ...text.split("\n"), "```ts", "open"])).toEqual([
|
||||
{ open: 1, close: 4, info: "terminal zsh" },
|
||||
]);
|
||||
expect(fenceLabel("terminal zsh")).toEqual({ kind: "terminal", label: "zsh", language: null });
|
||||
expect(fenceLabel("trace")).toEqual({ kind: "trace", label: "", language: null });
|
||||
expect(fenceLabel("ts")).toEqual({ kind: "code", label: "ts", language: "ts" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasted traces", () => {
|
||||
const node =
|
||||
"TypeError: x is undefined\n at fetchUser (/p/src/api/client.ts:12:5)\n at node_modules/lib/x.js:3:1";
|
||||
const python =
|
||||
'Traceback (most recent call last):\n File "/p/app/main.py", line 7, in <module>\n run()\nValueError: bad';
|
||||
const rust = "thread 'main' panicked at src/main.rs:4:5:\nboom";
|
||||
it("recognises stack traces, not prose", () => {
|
||||
expect([node, python, rust].map(looksLikeTrace)).toEqual([true, true, true]);
|
||||
expect(looksLikeTrace("see src/a.ts:3\nand more")).toBe(false);
|
||||
expect(looksLikeTrace("Error: one line")).toBe(false);
|
||||
});
|
||||
it("finds the places a trace names, leaving dependencies out", () => {
|
||||
expect(placesInText(node)).toEqual([{ path: "/p/src/api/client.ts", line: 12 }]);
|
||||
expect(placesInText(python)).toEqual([{ path: "/p/app/main.py", line: 7 }]);
|
||||
expect(placesInText(rust)).toEqual([{ path: "src/main.rs", line: 4 }]);
|
||||
expect(placesInText("a.ts:1 a.ts:1 b.ts:2", 1)).toEqual([{ path: "a.ts", line: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pastedPaths", () => {
|
||||
it("reads absolute paths with lines, quotes and file URLs, one per line", () => {
|
||||
expect(pastedPaths("/a/b.ts:10-20\n '/a/c d.ts' \nfile:///a/%E4%B8%AD.ts\n/a/dir/\n/a/e.ts:3:7")).toEqual([
|
||||
{ path: "/a/b.ts", lines: { from: 10, to: 20 } },
|
||||
{ path: "/a/c d.ts" },
|
||||
{ path: "/a/中.ts" },
|
||||
{ path: "/a/dir" },
|
||||
{ path: "/a/e.ts", lines: { from: 3, to: 3 } },
|
||||
]);
|
||||
expect(pastedPaths("C:\\work\\a.ts:4")).toEqual([{ path: "C:\\work\\a.ts", lines: { from: 4, to: 4 } }]);
|
||||
expect(pastedPaths("/a/b.ts\nnot a path")).toBeNull();
|
||||
expect(pastedPaths("see /a/b.ts")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMatches", () => {
|
||||
it("ranks folders among files by score, files first on a tie", () => {
|
||||
const merged = mergeMatches(
|
||||
@@ -86,14 +140,38 @@ describe("mergeMatches", () => {
|
||||
3,
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ path: "/p/docs", rel: "docs", dir: true, score: 70 },
|
||||
{ path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 },
|
||||
{ path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 },
|
||||
{ path: "/p/docs", rel: "docs", dir: true, score: 70, indices: [] },
|
||||
{
|
||||
path: "/p/src/agents.ts",
|
||||
rel: "src/agents.ts",
|
||||
dir: false,
|
||||
score: 50,
|
||||
indices: [],
|
||||
},
|
||||
{
|
||||
path: "/p/src/agents",
|
||||
rel: "src/agents",
|
||||
dir: true,
|
||||
score: 50,
|
||||
indices: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("leaves punctuation after a reference or command to the text", () => {
|
||||
expect(messageTokens("in @src/a.ts:3-8, keep @src/retry/。then /review.")).toEqual([
|
||||
{ kind: "text", value: "in " },
|
||||
{ kind: "file", value: "@src/a.ts:3-8" },
|
||||
{ kind: "text", value: ", keep " },
|
||||
{ kind: "file", value: "@src/retry/" },
|
||||
{ kind: "text", value: "。then " },
|
||||
{ kind: "command", value: "/review" },
|
||||
{ kind: "text", value: "." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("completes @files and /commands anywhere at a word boundary", () => {
|
||||
expect(completionAt("fix @src/le", 11)).toEqual({
|
||||
kind: "file",
|
||||
@@ -186,7 +264,11 @@ describe("agent composer", () => {
|
||||
expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]);
|
||||
expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]);
|
||||
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
|
||||
const video = { path: "/home/me/clip.mov", rel: "clip.mov", attached: true };
|
||||
const video = {
|
||||
path: "/home/me/clip.mov",
|
||||
rel: "clip.mov",
|
||||
attached: true,
|
||||
};
|
||||
expect(toPrompt({ text: "what happens here?", files: [video], images: [] }).files).toEqual(["/home/me/clip.mov"]);
|
||||
expect(canSend({ text: " ", files: [video], images: [] })).toBe(true);
|
||||
});
|
||||
@@ -487,16 +569,23 @@ describe("new task defaults", () => {
|
||||
description: null,
|
||||
category: id,
|
||||
current,
|
||||
values: values.map((value) => ({ value, name: value, description: null })),
|
||||
values: values.map((value) => ({
|
||||
value,
|
||||
name: value,
|
||||
description: null,
|
||||
})),
|
||||
via: "option",
|
||||
}) as AgentConfigOption;
|
||||
const provider = {
|
||||
id: "claude",
|
||||
config: [option("model", "sonnet", ["sonnet", "opus"]), option("mode", "default", ["default", "plan"])],
|
||||
} as unknown as Parameters<typeof draftConfig>[0];
|
||||
const draft = { provider: "claude", config: {}, isolation: "local", base: null } as unknown as Parameters<
|
||||
typeof draftConfig
|
||||
>[1];
|
||||
const draft = {
|
||||
provider: "claude",
|
||||
config: {},
|
||||
isolation: "local",
|
||||
base: null,
|
||||
} as unknown as Parameters<typeof draftConfig>[1];
|
||||
const current = (d: typeof draft, defaults: Record<string, string>) =>
|
||||
draftConfig(provider, d, defaults).map((o) => o.current);
|
||||
expect(current(draft, { model: "opus", mode: "nope" })).toEqual(["opus", "default"]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendKeyAction } from "./Composer";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -25,7 +25,13 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
type DragDrop = { payload: { type: string; paths?: string[]; position?: { x: number; y: number } } };
|
||||
type DragDrop = {
|
||||
payload: {
|
||||
type: string;
|
||||
paths?: string[];
|
||||
position?: { x: number; y: number };
|
||||
};
|
||||
};
|
||||
const dragDrop = new Set<(event: DragDrop) => void>();
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
@@ -43,12 +49,11 @@ vi.mock("@/platform/tauriCore", async (importOriginal) => ({
|
||||
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import type { ProjectInfo } from "@/bindings";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import composerStyles from "./Composer.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
import { composerDraft, HOME, updateComposer, useAgents } from "./store";
|
||||
import { emptyDraft } from "./composerModel";
|
||||
|
||||
@@ -82,14 +87,21 @@ beforeEach(() => {
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
useProject.setState({ project: null });
|
||||
vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] });
|
||||
vcsBranchInfo
|
||||
.mockReset()
|
||||
.mockImplementation((root: string | null) =>
|
||||
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
|
||||
);
|
||||
vcsBranchInfo.mockReset().mockImplementation((root: string | null) =>
|
||||
Promise.resolve({
|
||||
status: "ok",
|
||||
data: { head: root === "/project-b" ? "b-main" : "a-main" },
|
||||
}),
|
||||
);
|
||||
agentCommands.mockReset();
|
||||
clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" });
|
||||
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
|
||||
clipboardImage.mockReset().mockResolvedValue({
|
||||
status: "ok",
|
||||
data: "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
agentCreate.mockReset().mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "invalid", message: "stop here" },
|
||||
});
|
||||
useAgents.setState({
|
||||
providers: [claude],
|
||||
draft: { provider: "claude", config: {}, isolation: "local", base: null },
|
||||
@@ -106,11 +118,11 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
typeIn(box, "/");
|
||||
await screen.findByRole("listbox", { name: "Commands" });
|
||||
for (let i = 0; i < 29; i++) fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(box).toHaveValue("/command-29 ");
|
||||
expect(textOf(box)).toBe("/command-29 ");
|
||||
});
|
||||
|
||||
it("groups commands before skills, skips headers, wraps and hides empty groups", async () => {
|
||||
@@ -121,7 +133,7 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/" } });
|
||||
typeIn(box, "/");
|
||||
const list = await screen.findByRole("listbox", { name: "Commands" });
|
||||
expect(
|
||||
within(list)
|
||||
@@ -137,11 +149,11 @@ describe("New Task composer commands", () => {
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/review");
|
||||
fireEvent.keyDown(box, { key: "ArrowDown" });
|
||||
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/compact");
|
||||
fireEvent.change(box, { target: { value: "/tri" } });
|
||||
typeIn(box, "/tri");
|
||||
expect(list.textContent).toBe("Skills/triage");
|
||||
fireEvent.keyDown(box, { key: "Tab" });
|
||||
expect(box).toHaveValue("/triage ");
|
||||
fireEvent.change(box, { target: { value: "/com" } });
|
||||
expect(textOf(box)).toBe("/triage ");
|
||||
typeIn(box, "/com");
|
||||
expect((await screen.findByRole("listbox", { name: "Commands" })).textContent).toBe("Commands/compact");
|
||||
});
|
||||
|
||||
@@ -154,7 +166,7 @@ describe("New Task composer commands", () => {
|
||||
expect(start.textContent).toBe("");
|
||||
expect(start).toHaveAttribute("data-tooltip", "Start");
|
||||
expect(start).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Do the task" } });
|
||||
typeIn(screen.getByRole("textbox"), "Do the task");
|
||||
expect(start).toBeEnabled();
|
||||
fireEvent.click(start);
|
||||
const starting = screen.getByRole("button", { name: "Starting…" });
|
||||
@@ -163,7 +175,12 @@ describe("New Task composer commands", () => {
|
||||
expect(starting).toHaveAttribute("data-tooltip", "Starting…");
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(agentCreate).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish({ status: "error", error: { kind: "invalid", message: "stop here" } }));
|
||||
await act(async () =>
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "invalid", message: "stop here" },
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -179,7 +196,7 @@ describe("New Task composer commands", () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null));
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/trig" } });
|
||||
typeIn(box, "/trig");
|
||||
const list = await screen.findByRole("listbox", { name: "Commands" });
|
||||
const options = within(list).getAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]);
|
||||
@@ -188,95 +205,83 @@ describe("New Task composer commands", () => {
|
||||
expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage ");
|
||||
|
||||
// Sent as typed, so the agent runs the skill.
|
||||
fireEvent.change(box, { target: { value: "/triage dry-run" } });
|
||||
typeIn(box, "/triage dry-run");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" });
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
|
||||
prompt: { text: "/triage dry-run" },
|
||||
provider: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("tags file references and skills where they stand", async () => {
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over issues")] });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "12 @Cargo.toml and /triage" } });
|
||||
const mirror = document.querySelector(`.${composerStyles.mirror}`)!;
|
||||
expect(within(mirror as HTMLElement).getByText("@Cargo.toml")).toHaveClass(tagStyles.tag!);
|
||||
expect(within(mirror as HTMLElement).getByText("/triage")).toHaveClass(tagStyles.accent!);
|
||||
});
|
||||
|
||||
it("offers nothing the project does not have", async () => {
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("triage", "One pass over issues")],
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "/trig" } });
|
||||
typeIn(box, "12 @Cargo.toml and /triage");
|
||||
expect(within(box).getByText("@Cargo.toml")).toHaveClass(chipStyles.pending!);
|
||||
expect(within(box).getByText("/triage")).toHaveClass(chipStyles.command!);
|
||||
});
|
||||
|
||||
it("offers nothing the project does not have", async () => {
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("compact", "Free up context")],
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
typeIn(box, "/trig");
|
||||
expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull();
|
||||
fireEvent.change(box, { target: { value: "/com" } });
|
||||
typeIn(box, "/com");
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
|
||||
});
|
||||
|
||||
it("completes at the caret without replacing the rest of the message", async () => {
|
||||
const user = userEvent.setup();
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("compact", "Free up context")],
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
box.focus();
|
||||
fireEvent.change(box, { target: { value: "/com keep this context", selectionStart: 4, selectionEnd: 4 } });
|
||||
typeIn(box, "/com keep this context", 4);
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
|
||||
expect(box).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Tab}");
|
||||
expect(box).toHaveValue("/compact keep this context");
|
||||
expect(textOf(box)).toBe("/compact keep this context");
|
||||
await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull());
|
||||
expect(agentCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("completes a command typed after other text in place", async () => {
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over open issues and PRs")] });
|
||||
agentCommands.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [command("triage", "One pass over open issues and PRs")],
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
|
||||
const box = screen.getByRole("textbox");
|
||||
fireEvent.change(box, { target: { value: "fix the failing build /tri" } });
|
||||
typeIn(box, "fix the failing build /tri");
|
||||
await screen.findByRole("listbox", { name: "Commands" });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(useAgents.getState().composers.get(HOME)?.text).toBe("fix the failing build /triage ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("New Task composer Home / End", () => {
|
||||
const os = usePlatform.getState().os;
|
||||
afterEach(() => {
|
||||
usePlatform.setState({ os });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
const modify = vi.fn();
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection);
|
||||
render(<HomeEditor />);
|
||||
const box = screen.getByRole("textbox");
|
||||
expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false);
|
||||
fireEvent.keyDown(box, { key: "End", shiftKey: true });
|
||||
fireEvent.keyDown(box, { key: "End", metaKey: true });
|
||||
expect(modify.mock.calls).toEqual([
|
||||
["move", "backward", "lineboundary"],
|
||||
["extend", "forward", "lineboundary"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves Home / End to the text box elsewhere", () => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
render(<HomeEditor />);
|
||||
expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("New Task composer paste", () => {
|
||||
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } });
|
||||
fireEvent.paste(screen.getByRole("textbox"), {
|
||||
clipboardData: { types: [], files: [] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]),
|
||||
);
|
||||
@@ -284,7 +289,9 @@ describe("New Task composer paste", () => {
|
||||
|
||||
it("leaves a text paste to the text box", () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
|
||||
fireEvent.paste(screen.getByRole("textbox"), {
|
||||
clipboardData: { types: ["text/plain"], files: [] },
|
||||
});
|
||||
expect(clipboardImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -308,7 +315,11 @@ describe("New Task composer OS file drop", () => {
|
||||
expect(composerDraft(HOME)).toEqual({
|
||||
text: "",
|
||||
files: [
|
||||
{ path: "/Users/me/Desktop/recording one.mov", rel: "recording one.mov", attached: true },
|
||||
{
|
||||
path: "/Users/me/Desktop/recording one.mov",
|
||||
rel: "recording one.mov",
|
||||
attached: true,
|
||||
},
|
||||
{ path: "/Users/me/Desktop/huge.png", rel: "huge.png", attached: true },
|
||||
],
|
||||
images: [{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "shot.png" }],
|
||||
@@ -327,7 +338,11 @@ describe("workspace task project", () => {
|
||||
name: "Workspace",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
|
||||
workspace: {
|
||||
version: 1,
|
||||
name: "Workspace",
|
||||
projects: ["/project-a", "/project-b"],
|
||||
},
|
||||
} as ProjectInfo;
|
||||
it("defaults to the active member, last used member, then first member", () => {
|
||||
expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b");
|
||||
@@ -339,7 +354,13 @@ describe("workspace task project", () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
useProject.setState({ project });
|
||||
agentCommands.mockResolvedValue({ status: "ok", data: [] });
|
||||
useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } });
|
||||
useAgents.setState({
|
||||
draft: {
|
||||
...useAgents.getState().draft,
|
||||
projectRoot: "/project-b",
|
||||
isolation,
|
||||
},
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
const selector = screen.getByRole("button", { name: "Project" });
|
||||
expect(selector).toHaveTextContent("project-b");
|
||||
@@ -348,11 +369,14 @@ describe("workspace task project", () => {
|
||||
fireEvent.click(selector);
|
||||
expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("option", { name: /project-a/ }));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } });
|
||||
typeIn(screen.getByRole("textbox"), "Update member");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled());
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation });
|
||||
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
|
||||
projectRoot: "/project-a",
|
||||
isolation,
|
||||
});
|
||||
expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a");
|
||||
expect(vcsBranches).toHaveBeenLastCalledWith("/project-a");
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* The composer's CodeMirror extensions. The text stays plain (`@src/a.rs:10-20`, `/triage`,
|
||||
* `> quote`) and is what's stored and sent; references to files of the draft are drawn as
|
||||
* chips over their text, commands tagged, and quote lines as a block without their `>`.
|
||||
*
|
||||
* A chip moves and deletes as one piece. Backspace right after one turns it back into its
|
||||
* text to edit (the completion opens on it); it folds again once the caret leaves it.
|
||||
*
|
||||
* A ``` block (pasted code or terminal output) is a card while the caret is outside it; a click
|
||||
* on the card, or the caret at its end, opens it as text.
|
||||
*/
|
||||
import { EditorState, StateEffect, StateField, type Extension, type Range, type Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView, WidgetType, keymap, type DecorationSet } from "@codemirror/view";
|
||||
import { fencedBlocks, isQuoteLine, messageTokens, parseMention, type FileRef, type Mention } from "./composerModel";
|
||||
import { chipElement, chipStyles, codeCardElement, openReference } from "./ReferenceChip";
|
||||
|
||||
/** The draft's referenced files: mentions of these are chips. */
|
||||
export const setReferences = StateEffect.define<readonly FileRef[]>();
|
||||
const references = StateField.define<readonly FileRef[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setReferences)) return e.value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
interface Chip {
|
||||
from: number;
|
||||
to: number;
|
||||
path: string;
|
||||
mention: Mention;
|
||||
}
|
||||
|
||||
/** `@` and `/` tokens of the line around `pos`, in document positions. */
|
||||
function lineTokens(doc: Text, pos: number) {
|
||||
const line = doc.lineAt(pos);
|
||||
const out: {
|
||||
kind: "file" | "command";
|
||||
from: number;
|
||||
to: number;
|
||||
value: string;
|
||||
}[] = [];
|
||||
let offset = line.from;
|
||||
for (const token of messageTokens(line.text)) {
|
||||
if (token.kind !== "text")
|
||||
out.push({
|
||||
kind: token.kind,
|
||||
from: offset,
|
||||
to: offset + token.value.length,
|
||||
value: token.value,
|
||||
});
|
||||
offset += token.value.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const unfold = StateEffect.define<number>();
|
||||
/** Start of the chip being edited as text, while the caret stays in it. */
|
||||
const unfolded = StateField.define<number | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(unfold)) return e.value;
|
||||
if (value === null) return null;
|
||||
const start = tr.changes.mapPos(value, 1);
|
||||
const head = tr.state.selection.main.head;
|
||||
const token = lineTokens(tr.state.doc, start).find((t) => t.from === start && t.kind === "file");
|
||||
return token && head > token.from && head <= token.to ? start : null;
|
||||
},
|
||||
});
|
||||
|
||||
class ChipWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly mention: Mention,
|
||||
readonly text: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: ChipWidget) {
|
||||
return other.path === this.path && other.text === this.text;
|
||||
}
|
||||
toDOM() {
|
||||
const chip = chipElement(this.path, this.mention);
|
||||
// Keep the editor's focus and caret; a click opens the file.
|
||||
chip.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openReference(this.path, this.mention);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class CodeCardWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly info: string,
|
||||
readonly body: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: CodeCardWidget) {
|
||||
return other.info === this.info && other.body === this.body;
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const range = (dom: HTMLElement) => {
|
||||
const from = view.posAtDOM(dom);
|
||||
return view.state.field(painted).cards.find((c) => c.from === from) ?? null;
|
||||
};
|
||||
const card: HTMLElement = codeCardElement(
|
||||
this.info,
|
||||
this.body,
|
||||
() => {
|
||||
const at = range(card);
|
||||
if (at) view.dispatch({ selection: { anchor: at.to } });
|
||||
view.focus();
|
||||
},
|
||||
() => {
|
||||
const at = range(card);
|
||||
if (!at) return;
|
||||
const end = view.state.sliceDoc(at.to, at.to + 1) === "\n" ? at.to + 1 : at.to;
|
||||
view.dispatch({ changes: { from: at.from, to: end }, userEvent: "delete" });
|
||||
view.focus();
|
||||
},
|
||||
);
|
||||
return card;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const quoteMarker = Decoration.replace({});
|
||||
const codeLine = Decoration.line({ class: chipStyles.codeLine });
|
||||
|
||||
interface Painted {
|
||||
decorations: DecorationSet;
|
||||
atomic: DecorationSet;
|
||||
chips: readonly Chip[];
|
||||
cards: readonly { from: number; to: number }[];
|
||||
}
|
||||
|
||||
function paint(state: EditorState): Painted {
|
||||
const refs = state.field(references);
|
||||
const editing = state.field(unfolded);
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const atomic: Range<Decoration>[] = [];
|
||||
const chips: Chip[] = [];
|
||||
const cards: { from: number; to: number }[] = [];
|
||||
const { doc } = state;
|
||||
const head = state.selection.main.head;
|
||||
// Line numbers (1-based) inside ``` blocks: no chips or quotes there.
|
||||
const coded = new Set<number>();
|
||||
for (const fence of fencedBlocks(doc.toString().split("\n"))) {
|
||||
const from = doc.line(fence.open + 1).from;
|
||||
const to = doc.line(fence.close + 1).to;
|
||||
for (let n = fence.open + 1; n <= fence.close + 1; n++) coded.add(n);
|
||||
if (head > from && head <= to) {
|
||||
for (let n = fence.open + 1; n <= fence.close + 1; n++) decorations.push(codeLine.range(doc.line(n).from));
|
||||
continue;
|
||||
}
|
||||
const body =
|
||||
fence.close > fence.open + 1 ? doc.sliceString(doc.line(fence.open + 2).from, doc.line(fence.close).to) : "";
|
||||
const card = Decoration.replace({ widget: new CodeCardWidget(fence.info, body), block: true });
|
||||
decorations.push(card.range(from, to));
|
||||
atomic.push(card.range(from, to));
|
||||
cards.push({ from, to });
|
||||
}
|
||||
for (let n = 1; n <= doc.lines; n++) {
|
||||
if (coded.has(n)) continue;
|
||||
const line = doc.line(n);
|
||||
if (isQuoteLine(line.text)) {
|
||||
const prev = n > 1 && isQuoteLine(doc.line(n - 1).text);
|
||||
const next = n < doc.lines && isQuoteLine(doc.line(n + 1).text);
|
||||
const cls = [chipStyles.quoteLine, !prev && chipStyles.quoteFirst, !next && chipStyles.quoteLast];
|
||||
decorations.push(Decoration.line({ class: cls.filter(Boolean).join(" ") }).range(line.from));
|
||||
const marker = line.text.startsWith("> ") ? 2 : 1;
|
||||
decorations.push(quoteMarker.range(line.from, line.from + marker));
|
||||
atomic.push(quoteMarker.range(line.from, line.from + marker));
|
||||
}
|
||||
for (const token of lineTokens(doc, line.from)) {
|
||||
if (token.kind === "command") {
|
||||
decorations.push(Decoration.mark({ class: chipStyles.command }).range(token.from, token.to));
|
||||
continue;
|
||||
}
|
||||
const mention = parseMention(token.value);
|
||||
const ref = mention && refs.find((f) => f.rel === mention.rel);
|
||||
if (!mention || !ref || editing === token.from) {
|
||||
decorations.push(Decoration.mark({ class: chipStyles.pending }).range(token.from, token.to));
|
||||
continue;
|
||||
}
|
||||
const chip = Decoration.replace({
|
||||
widget: new ChipWidget(ref.path, mention, token.value),
|
||||
});
|
||||
decorations.push(chip.range(token.from, token.to));
|
||||
atomic.push(chip.range(token.from, token.to));
|
||||
chips.push({ from: token.from, to: token.to, path: ref.path, mention });
|
||||
}
|
||||
}
|
||||
return {
|
||||
decorations: Decoration.set(decorations, true),
|
||||
atomic: Decoration.set(atomic, true),
|
||||
chips,
|
||||
cards,
|
||||
};
|
||||
}
|
||||
|
||||
const painted = StateField.define<Painted>({
|
||||
create: paint,
|
||||
update(value, tr) {
|
||||
// A ``` block opens while the caret is in it.
|
||||
const fences = tr.selection && tr.state.doc.toString().includes("```");
|
||||
const changed =
|
||||
tr.docChanged ||
|
||||
fences ||
|
||||
tr.effects.some((e) => e.is(setReferences) || e.is(unfold)) ||
|
||||
tr.startState.field(unfolded) !== tr.state.field(unfolded);
|
||||
return changed ? paint(tr.state) : value;
|
||||
},
|
||||
provide: (f) => [
|
||||
EditorView.decorations.from(f, (p) => p.decorations),
|
||||
EditorView.atomicRanges.of((view) => view.state.field(f).atomic),
|
||||
],
|
||||
});
|
||||
|
||||
/** Backspace right after a chip turns it back into its text. */
|
||||
const unfoldOnBackspace = keymap.of([
|
||||
{
|
||||
key: "Backspace",
|
||||
run(view) {
|
||||
const sel = view.state.selection.main;
|
||||
if (!sel.empty) return false;
|
||||
const chip = view.state.field(painted).chips.find((c) => c.to === sel.head);
|
||||
if (!chip) return false;
|
||||
view.dispatch({ effects: unfold.of(chip.from), userEvent: "unfold" });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export function referenceExtensions(): Extension {
|
||||
return [references, unfolded, painted, unfoldOnBackspace];
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { copySourceOf, noteCopySource } from "@/platform/clipboardSource";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { trackEditorCopies } from "./references";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { caretOf, setCaret, textOf, typeChars, typeIn } from "./composerTesting";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { FILES_DRAG, dragSession } from "@/ui";
|
||||
|
||||
const findFiles = vi.fn();
|
||||
const findDirs = vi.fn();
|
||||
const pathCompletions = vi.fn();
|
||||
const fsProbe = vi.fn();
|
||||
const fsEntryKind = vi.fn();
|
||||
const findSymbols = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
@@ -17,11 +25,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
findFiles: (query: string, limit: number) => findFiles(query, limit),
|
||||
findDirs: (query: string, limit: number) => findDirs(query, limit),
|
||||
pathCompletions: (base: string, typed: string) => pathCompletions(base, typed),
|
||||
fsProbe: (path: string) => fsProbe(path),
|
||||
fsEntryKind: (path: string) => fsEntryKind(path),
|
||||
findSymbols: (query: string, filter: string, limit: number) => findSymbols(query, filter, limit),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Composer } from "./Composer";
|
||||
import { registerAgentContextSource } from "@/workbench/contributions";
|
||||
import { useEditorRegistry, type TextEditorHandle } from "@/editor/api";
|
||||
import { composerDraft, updateComposer } from "./store";
|
||||
import { emptyDraft, toPrompt } from "./composerModel";
|
||||
|
||||
@@ -41,15 +54,33 @@ function renderComposer(root = "/p") {
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement;
|
||||
return screen.getByRole("textbox", { name: "Message" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateComposer(ID, emptyDraft());
|
||||
const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] });
|
||||
const match = (path: string, relPath: string, score: number) => ({
|
||||
path,
|
||||
relPath,
|
||||
score,
|
||||
indices: [],
|
||||
});
|
||||
findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)]));
|
||||
findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)]));
|
||||
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
|
||||
fsEntryKind
|
||||
.mockReset()
|
||||
.mockImplementation((path: string) =>
|
||||
path.startsWith("/p/")
|
||||
? ok(path.endsWith(".ts") ? "file" : "dir")
|
||||
: Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }),
|
||||
);
|
||||
fsProbe.mockReset().mockReturnValue(
|
||||
Promise.resolve({
|
||||
status: "error",
|
||||
error: { kind: "notFound", message: "" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Composer dropped paths", () => {
|
||||
@@ -73,7 +104,10 @@ describe("Composer dropped paths", () => {
|
||||
try {
|
||||
act(() => {
|
||||
// Folders carry a trailing slash, as the Project view marks them.
|
||||
const drag = dragSession({ type: FILES_DRAG, payload: "/p/src/a.ts\n/p/src/dir/" });
|
||||
const drag = dragSession({
|
||||
type: FILES_DRAG,
|
||||
payload: "/p/src/a.ts\n/p/src/dir/",
|
||||
});
|
||||
expect(drag.drop(0, 0)).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
@@ -83,24 +117,24 @@ describe("Composer dropped paths", () => {
|
||||
{ path: "/p/src/a.ts", rel: "src/a.ts" },
|
||||
{ path: "/p/src/dir", rel: "src/dir" },
|
||||
]);
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
});
|
||||
|
||||
it("deduplicates live references and allows a deleted reference to be dropped again", () => {
|
||||
const box = renderComposer();
|
||||
drop(box, "/p/src/a.ts\n/p/src/a.ts\n/p/src/dir/");
|
||||
drop(box, "/p/src/a.ts\n/p/src/dir/");
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
fireEvent.change(box, { target: { value: "@src/dir " } });
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
typeIn(box, "@src/dir ");
|
||||
drop(box, "/p/src/a.ts");
|
||||
expect(box.value).toBe("@src/dir @src/a.ts ");
|
||||
expect(textOf(box)).toBe("@src/dir @src/a.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("maps project files and folders into the task worktree, rejecting external paths", () => {
|
||||
const box = renderComposer("/worktree");
|
||||
drop(box, "/p/src/a.ts\n/p/src/dir/\n/external/a.ts\n/p-other/a.ts\n/p/../external/b.ts\nrelative.ts");
|
||||
expect(box.value).toBe("@src/a.ts @src/dir ");
|
||||
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/worktree/src/a.ts", "/worktree/src/dir"]);
|
||||
});
|
||||
});
|
||||
@@ -110,139 +144,349 @@ describe("Composer @ folders", () => {
|
||||
findFiles.mockReturnValue(ok([]));
|
||||
findDirs.mockReturnValue(ok([]));
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "@");
|
||||
typeChars(box, "@");
|
||||
expect(await screen.findByRole("option")).toHaveTextContent("lib/");
|
||||
expect(pathCompletions).toHaveBeenCalledWith("/p/_", "");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("@lib/ ");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@lib/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
|
||||
});
|
||||
|
||||
it("lists index folders among files and references one on Enter", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "see @agen");
|
||||
typeChars(box, "see @agen");
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("see @src/agents/ ");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["agents/src", "agents.tssrc"]);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("see @src/agents/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
});
|
||||
|
||||
it("highlights the matched characters of a file name", async () => {
|
||||
findFiles.mockReturnValue(
|
||||
ok([
|
||||
{
|
||||
path: "/p/src/agents.ts",
|
||||
relPath: "src/agents.ts",
|
||||
score: 40,
|
||||
indices: [4, 5, 6],
|
||||
},
|
||||
]),
|
||||
);
|
||||
findDirs.mockReturnValue(ok([]));
|
||||
const box = renderComposer();
|
||||
typeChars(box, "@age");
|
||||
const option = await screen.findByRole("option");
|
||||
expect([...option.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["age"]);
|
||||
});
|
||||
|
||||
it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "@agen");
|
||||
typeChars(box, "@agen");
|
||||
await screen.findAllByRole("option");
|
||||
await userEvent.keyboard("{Tab}");
|
||||
expect(box.value).toBe("@src/agents/");
|
||||
fireEvent.keyDown(box, { key: "Tab" });
|
||||
expect(textOf(box)).toBe("@src/agents/");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
|
||||
await userEvent.type(box, "x.ts ");
|
||||
typeChars(box, "x.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual([]);
|
||||
});
|
||||
|
||||
it("references a folder of a typed path", async () => {
|
||||
const box = renderComposer();
|
||||
await userEvent.type(box, "@./");
|
||||
typeChars(box, "@./");
|
||||
await screen.findByRole("option");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(box.value).toBe("@./lib/ ");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@./lib/ ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer tag editing", () => {
|
||||
it.each(["@src/ag", "/rev"])("deletes %s character by character while it is typed", async (tag) => {
|
||||
describe("Composer reference chips", () => {
|
||||
const chips = () => [...document.querySelectorAll<HTMLElement>("[data-ref-path]")];
|
||||
|
||||
it("shows a referenced file as a chip with its name and lines, and leaves the text as written", () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, `see ${tag}`);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -1)}`);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -2)}`);
|
||||
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
|
||||
typeIn(box, "see @src/a.ts:10-20 and @other.ts ");
|
||||
expect(chips().map((c) => [c.dataset.refPath, c.textContent])).toEqual([["/p/src/a.ts", "a.ts10–20"]]);
|
||||
expect(chips()[0]).toHaveAccessibleName("File reference a.ts, lines 10–20");
|
||||
// Not referenced: marked as typed.
|
||||
expect(screen.getByText("@other.ts")).toHaveClass(chipStyles.pending!);
|
||||
expect(textOf(box)).toBe("see @src/a.ts:10-20 and @other.ts ");
|
||||
});
|
||||
|
||||
it("deletes inside a folder entered with Tab character by character", async () => {
|
||||
it("turns a chip back into its text on Backspace, and folds it again when the caret leaves", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, "@agen");
|
||||
typeChars(box, "see @agen");
|
||||
await screen.findAllByRole("option");
|
||||
await user.keyboard("{Tab}");
|
||||
expect(box.value).toBe("@src/agents/");
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe("@src/agents");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("see @src/agents/ ");
|
||||
expect(chips()).toHaveLength(1);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents/");
|
||||
expect(chips()).toHaveLength(1);
|
||||
// Right after the chip: it opens as text, and the completion offers it again.
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents/");
|
||||
expect(chips()).toHaveLength(0);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/agents");
|
||||
expect(chips()).toHaveLength(0);
|
||||
typeChars(box, "/");
|
||||
setCaret(box, 0);
|
||||
expect(chips()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("edits a completed reference character by character after its space", async () => {
|
||||
it("moves over and deletes a chip as one piece", () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
await user.type(box, "see @agen");
|
||||
await screen.findAllByRole("option");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(box.value).toBe("see @src/agents/ ");
|
||||
await user.keyboard("{Backspace}{Backspace}");
|
||||
expect(box.value).toBe("see @src/agents");
|
||||
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
|
||||
typeIn(box, "see @src/a.ts next", 4);
|
||||
fireEvent.keyDown(box, { key: "Delete" });
|
||||
expect(textOf(box)).toBe("see next");
|
||||
});
|
||||
|
||||
it.each(["@src/a.ts", "/review"])("edits %s after moving away and returning", async (tag) => {
|
||||
it("deletes a reference that names no file character by character", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "see @src/a.ts next", 13);
|
||||
fireEvent.keyDown(box, { key: "Backspace" });
|
||||
expect(textOf(box)).toBe("see @src/a.t next");
|
||||
expect(caretOf(box)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer quotes", () => {
|
||||
it("draws quote lines as a block without their marker", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "> first\n> second\n\nreply");
|
||||
const lines = [...box.querySelectorAll(".cm-line")];
|
||||
expect(lines.map((l) => l.textContent)).toEqual(["first", "second", "", "reply"]);
|
||||
expect(lines.map((l) => l.classList.contains(chipStyles.quoteLine!))).toEqual([true, true, false, false]);
|
||||
expect(textOf(box)).toBe("> first\n> second\n\nreply");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer editing", () => {
|
||||
it("offers no completion while the input method composes", () => {
|
||||
const box = renderComposer();
|
||||
fireEvent.compositionStart(box);
|
||||
typeIn(box, "@中文");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
fireEvent.compositionEnd(box);
|
||||
expect(textOf(box)).toBe("@中文");
|
||||
});
|
||||
|
||||
it("pastes plain text as text", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
act(() => box.focus());
|
||||
await user.paste("look @src/a.ts\nthen /review please");
|
||||
expect(textOf(box)).toBe("look @src/a.ts\nthen /review please");
|
||||
});
|
||||
|
||||
it("references a pasted path of a project file, with its lines", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(0, 0);
|
||||
fireEvent.select(box);
|
||||
box.blur();
|
||||
box.focus();
|
||||
box.setSelectionRange(4 + tag.length, 4 + tag.length);
|
||||
fireEvent.select(box);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, -1)} next`);
|
||||
expect(box.selectionStart).toBe(3 + tag.length);
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/src/a.ts:3-5");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("@src/a.ts:3-5 "));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
|
||||
await user.paste("/elsewhere/b.ts");
|
||||
expect(textOf(box)).toBe("@src/a.ts:3-5 /elsewhere/b.ts");
|
||||
});
|
||||
|
||||
it.each(["@src/a.ts", "/review"])("moves within %s with the arrow keys", async (tag) => {
|
||||
it("references pasted folders, several paths and file URLs", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(4, 4);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(box.selectionStart).toBe(5);
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box.selectionStart).toBe(4);
|
||||
await user.keyboard("{Delete}");
|
||||
expect(box.value).toBe(`see ${tag.slice(1)} next`);
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/src/dir\n'/p/src/b.ts'\nfile:///p/src/c.ts");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("@src/dir/ @src/b.ts @src/c.ts "));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/b.ts", "/p/src/c.ts"]);
|
||||
});
|
||||
|
||||
it.each(["@src/a.ts", "/review"])("selects and deletes only part of %s", async (tag) => {
|
||||
it("pastes a path that is gone, or holds a space, as text", async () => {
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
fireEvent.change(box, { target: { value: `see ${tag} next` } });
|
||||
box.focus();
|
||||
box.setSelectionRange(5, 7);
|
||||
fireEvent.select(box);
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(box.value).toBe(`see ${tag.slice(0, 1)}${tag.slice(3)} next`);
|
||||
act(() => box.focus());
|
||||
await user.paste("/p/my dir/a.ts");
|
||||
expect(textOf(box)).toBe("/p/my dir/a.ts");
|
||||
fsEntryKind.mockReturnValue(Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }));
|
||||
await user.paste(" /p/gone.ts");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe("/p/my dir/a.ts /p/gone.ts"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer current file", () => {
|
||||
it("offers the focused editor's file until the draft references it", () => {
|
||||
const view = {} as TextEditorHandle["view"];
|
||||
act(() =>
|
||||
useEditorRegistry.setState({ active: { view, ctx: { path: "/p/src/main.ts" } as TextEditorHandle["ctx"] } }),
|
||||
);
|
||||
try {
|
||||
renderComposer();
|
||||
const offer = screen.getByRole("button", { name: /main\.ts/ });
|
||||
expect(offer).toHaveTextContent("Current File");
|
||||
fireEvent.click(offer);
|
||||
expect(composerDraft(ID).text).toBe("@src/main.ts ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/main.ts"]);
|
||||
expect(screen.queryByRole("button", { name: /main\.ts/ })).toBeNull();
|
||||
expect(document.querySelector("[data-ref-path]")).toHaveAttribute("data-ref-path", "/p/src/main.ts");
|
||||
} finally {
|
||||
act(() => useEditorRegistry.setState({ active: null }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer pasted code and terminal output", () => {
|
||||
const paste = (box: HTMLElement, text: string) =>
|
||||
act(() => {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: { files: [], types: ["text/plain"], getData: () => text },
|
||||
});
|
||||
box.dispatchEvent(event);
|
||||
});
|
||||
|
||||
it("turns terminal output into a terminal card, and opens it as text when the caret enters it", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "why does this fail?");
|
||||
noteCopySource("$ pnpm test\nFAIL a.test.ts\n expected 1", { kind: "terminal", title: "zsh" });
|
||||
paste(box, "$ pnpm test\nFAIL a.test.ts\n expected 1\n");
|
||||
expect(textOf(box)).toBe("why does this fail?\n```terminal zsh\n$ pnpm test\nFAIL a.test.ts\n expected 1\n```\n");
|
||||
const card = screen.getByRole("group", { name: "Terminal · zsh" });
|
||||
expect(card).toHaveTextContent("3 lines");
|
||||
expect(card.querySelector("pre")).toHaveTextContent("$ pnpm test");
|
||||
fireEvent.click(card);
|
||||
expect(screen.queryByRole("group")).toBeNull();
|
||||
expect(box.querySelectorAll(`.${chipStyles.codeLine}`)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("removes a card with its button", () => {
|
||||
const box = renderComposer();
|
||||
typeIn(box, "```ts\nconst a = 1;\n```\nnext");
|
||||
fireEvent.click(within(screen.getByRole("group", { name: "TypeScript" })).getByRole("button", { name: "Remove" }));
|
||||
expect(textOf(box)).toBe("next");
|
||||
});
|
||||
|
||||
it("references the lines copied from an editor, and pastes a copied single line as text", () => {
|
||||
const box = renderComposer();
|
||||
noteCopySource("fn a() {\n}", { kind: "file", path: "/p/src/a.rs", from: 3, to: 4 });
|
||||
paste(box, "fn a() {\n}");
|
||||
expect(textOf(box)).toBe("@src/a.rs:3-4 ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.rs"]);
|
||||
noteCopySource("fn a()", { kind: "file", path: "/p/src/a.rs", from: 3, to: 3 });
|
||||
paste(box, "fn a()");
|
||||
expect(textOf(box)).toBe("@src/a.rs:3-4 fn a()");
|
||||
});
|
||||
|
||||
it("pastes a stack trace as a card, its project lines as references after it", async () => {
|
||||
const box = renderComposer();
|
||||
const trace = "TypeError: boom\n at run (/p/src/a.ts:12:5)\n at main (/elsewhere/x.ts:3:1)";
|
||||
paste(box, trace);
|
||||
expect(screen.getByRole("group", { name: "Stack Trace" })).toHaveTextContent("3 lines");
|
||||
await vi.waitFor(() => expect(textOf(box)).toBe(`\`\`\`trace\n${trace}\n\`\`\`\n@src/a.ts:12 `));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
|
||||
});
|
||||
|
||||
it("folds long pasted text into a card", () => {
|
||||
const box = renderComposer();
|
||||
const long = Array.from({ length: 40 }, (_, i) => `row ${i}`).join("\n");
|
||||
paste(box, long);
|
||||
expect(screen.getByRole("group", { name: "Pasted Text" })).toHaveTextContent("40 lines");
|
||||
expect(textOf(box)).toBe(`\`\`\`text\n${long}\n\`\`\`\n`);
|
||||
});
|
||||
|
||||
it("leaves IME keys and composition text to the input method", () => {
|
||||
it("pastes other text as written", () => {
|
||||
const box = renderComposer();
|
||||
fireEvent.change(box, { target: { value: "@src/a.ts" } });
|
||||
const before = box.value;
|
||||
box.setSelectionRange(before.length, before.length);
|
||||
expect(fireEvent.keyDown(box, { key: "Backspace", isComposing: true })).toBe(true);
|
||||
expect(fireEvent.keyDown(box, { key: "Backspace", keyCode: 229 })).toBe(true);
|
||||
fireEvent.compositionStart(box);
|
||||
fireEvent.change(box, { target: { value: "@中文" } });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
|
||||
fireEvent.compositionEnd(box);
|
||||
expect(box.value).toBe("@中文");
|
||||
noteCopySource("something else", { kind: "terminal", title: "zsh" });
|
||||
paste(box, "line one\nline two");
|
||||
expect(textOf(box)).toBe("line one\nline two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor copies", () => {
|
||||
it("notes the copied lines of a file editor, or the caret's line when nothing is selected", () => {
|
||||
const view = new EditorView({
|
||||
doc: "one\ntwo\nthree\nfour",
|
||||
parent: document.body,
|
||||
selection: { anchor: 4, head: 14 },
|
||||
});
|
||||
const handle = { view, ctx: { path: "/p/src/a.ts" } } as TextEditorHandle;
|
||||
act(() => useEditorRegistry.setState({ editors: new Set([handle]) }));
|
||||
const stop = trackEditorCopies();
|
||||
try {
|
||||
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
|
||||
expect(copySourceOf("two\nthree\n")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 2, to: 3 });
|
||||
view.dispatch({ selection: { anchor: 15 } });
|
||||
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
|
||||
expect(copySourceOf("four")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 4, to: 4 });
|
||||
} finally {
|
||||
stop();
|
||||
view.destroy();
|
||||
act(() => useEditorRegistry.setState({ editors: new Set() }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Composer @ context and symbols", () => {
|
||||
it("offers what features contribute above the files and inserts it", async () => {
|
||||
const off = [
|
||||
registerAgentContextSource({
|
||||
id: "problems",
|
||||
title: "Problems",
|
||||
description: "The project's errors and warnings",
|
||||
icon: "problems",
|
||||
resolve: async () => ({ kind: "block", info: "problems", text: "src/a.ts:3:1: error: nope" }),
|
||||
}),
|
||||
registerAgentContextSource({
|
||||
id: "changes",
|
||||
title: "Changes",
|
||||
description: "Files with uncommitted changes",
|
||||
icon: "diff",
|
||||
resolve: async () => ({ kind: "files", paths: ["/p/src/a.ts", "/p/src/b.ts", "/elsewhere/c.ts"] }),
|
||||
}),
|
||||
];
|
||||
try {
|
||||
const box = renderComposer();
|
||||
typeChars(box, "fix @pro");
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options[0]).toHaveTextContent("@problems");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await vi.waitFor(() =>
|
||||
expect(textOf(box)).toBe("fix\n```problems\nsrc/a.ts:3:1: error: nope\n```\n@src/a.ts:3 "),
|
||||
);
|
||||
expect(screen.getByRole("group", { name: "Problems" })).toBeInTheDocument();
|
||||
typeChars(box, "@chan");
|
||||
await screen.findAllByRole("option");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await vi.waitFor(() => expect(textOf(box)).toMatch(/@src\/a\.ts:3 @src\/a\.ts @src\/b\.ts $/));
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts", "/p/src/b.ts"]);
|
||||
} finally {
|
||||
off.forEach((d) => d());
|
||||
}
|
||||
});
|
||||
|
||||
it("pastes plain text through the native textarea", async () => {
|
||||
it("references a symbol's file and line with @#", async () => {
|
||||
findSymbols.mockReturnValue(
|
||||
ok([
|
||||
{
|
||||
name: "fetchUser",
|
||||
kind: "function",
|
||||
container: "client",
|
||||
path: "/p/src/api/client.ts",
|
||||
relPath: "src/api/client.ts",
|
||||
range: { start: { line: 11, col: 0 }, end: { line: 11, col: 9 } },
|
||||
score: 10,
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const box = renderComposer();
|
||||
const user = userEvent.setup();
|
||||
box.focus();
|
||||
await user.paste("look @src/a.ts\nthen /review please");
|
||||
expect(box.value).toBe("look @src/a.ts\nthen /review please");
|
||||
typeChars(box, "@#fet");
|
||||
const option = await screen.findByRole("option");
|
||||
expect(option).toHaveTextContent("fetchUserclient · src/api/client.ts:12");
|
||||
expect(findSymbols).toHaveBeenCalledWith("fet", "all", 12);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(textOf(box)).toBe("@src/api/client.ts:12 ");
|
||||
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/api/client.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -27,19 +27,26 @@ export interface ComposerDraft {
|
||||
images: AgentImage[];
|
||||
}
|
||||
|
||||
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
|
||||
export const emptyDraft = (): ComposerDraft => ({
|
||||
text: "",
|
||||
files: [],
|
||||
images: [],
|
||||
});
|
||||
|
||||
export type MessageToken = { kind: "text" | "file" | "command"; value: string };
|
||||
|
||||
/**
|
||||
* A message's text split into plain runs, `@file` references and `/command` skills. A
|
||||
* command is a `/word` at the start or after whitespace; a reference likewise after `@`.
|
||||
* Punctuation that ends either belongs to the text.
|
||||
*/
|
||||
export function messageTokens(text: string): MessageToken[] {
|
||||
const out: MessageToken[] = [];
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(/(^|\s)([@/][^\s@]+)/g)) {
|
||||
const token = match[2]!;
|
||||
// Full-width punctuation ends one too, as Chinese text runs on without a space after it.
|
||||
for (const match of text.matchAll(/(^|\s)([@/][^\s@,。;:!?、()【】《》「」『』]+)/g)) {
|
||||
// Punctuation after it ends the sentence, not the path: "see @a.ts, then /review."
|
||||
const token = match[2]!.replace(/^(.+?)[,.;:!?)\]}>'",。;:!?、)】》」』]+$/, "$1");
|
||||
const start = match.index! + match[1]!.length;
|
||||
const command = /^\/[A-Za-z][\w:.-]*$/.test(token);
|
||||
if (token.startsWith("/") && !command) continue;
|
||||
@@ -61,7 +68,12 @@ export function completionAt(text: string, caret: number): Completion | null {
|
||||
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
|
||||
if (!word) return null;
|
||||
const from = caret - word[3]!.length - 1;
|
||||
return { kind: word[2] === "/" ? "command" : "file", query: word[3]!, from, to: caret };
|
||||
return {
|
||||
kind: word[2] === "/" ? "command" : "file",
|
||||
query: word[3]!,
|
||||
from,
|
||||
to: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
|
||||
@@ -119,6 +131,8 @@ interface IndexMatch {
|
||||
path: string;
|
||||
relPath: string;
|
||||
score: number;
|
||||
/** Matched characters of `relPath`. */
|
||||
indices?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,15 +143,170 @@ export function mergeMatches(
|
||||
files: readonly IndexMatch[],
|
||||
dirs: readonly IndexMatch[],
|
||||
limit: number,
|
||||
): { path: string; rel: string; dir: boolean; score: number }[] {
|
||||
): {
|
||||
path: string;
|
||||
rel: string;
|
||||
dir: boolean;
|
||||
score: number;
|
||||
indices: number[];
|
||||
}[] {
|
||||
return [
|
||||
...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })),
|
||||
...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })),
|
||||
...files.map((f) => ({
|
||||
path: f.path,
|
||||
rel: f.relPath,
|
||||
dir: false,
|
||||
score: f.score,
|
||||
indices: f.indices ?? [],
|
||||
})),
|
||||
...dirs.map((d) => ({
|
||||
path: d.path,
|
||||
rel: d.relPath.replace(/\/$/, ""),
|
||||
dir: true,
|
||||
score: d.score,
|
||||
indices: d.indices ?? [],
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export interface Mention {
|
||||
/** As written after `@`; a folder keeps its trailing `/`. */
|
||||
rel: string;
|
||||
dir: boolean;
|
||||
/** 1-based lines, when the mention names a range. */
|
||||
lines?: { from: number; to: number };
|
||||
}
|
||||
|
||||
/** `@src/a.rs:10-20` → its path and lines; `null` for an empty `@`. */
|
||||
export function parseMention(token: string): Mention | null {
|
||||
const m = /^@(.+?)(?::(\d+)(?:-(\d+))?)?$/.exec(token);
|
||||
if (!m) return null;
|
||||
const rel = m[1]!;
|
||||
const from = m[2] ? Number(m[2]) : undefined;
|
||||
const lines = from === undefined ? undefined : { from, to: m[3] ? Math.max(from, Number(m[3])) : from };
|
||||
return { rel, dir: rel.endsWith("/"), ...(lines && { lines }) };
|
||||
}
|
||||
|
||||
/** "10" or "10–20". */
|
||||
export const linesLabel = (lines: { from: number; to: number }) =>
|
||||
lines.from === lines.to ? String(lines.from) : `${lines.from}–${lines.to}`;
|
||||
|
||||
/** Lines of `text` that start with `>`: a quoted reply or selection, drawn as a block. */
|
||||
export const isQuoteLine = (line: string) => /^>( |$)/.test(line);
|
||||
|
||||
export interface Fence {
|
||||
/** Line indexes (0-based) of the opening and the closing fence. */
|
||||
open: number;
|
||||
close: number;
|
||||
/** After the opening fence: a language, or `terminal <title>`. */
|
||||
info: string;
|
||||
}
|
||||
|
||||
/** The closed ``` blocks among `lines`. */
|
||||
export function fencedBlocks(lines: readonly string[]): Fence[] {
|
||||
const out: Fence[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const open = /^(`{3,})([^`]*)$/.exec(lines[i]!);
|
||||
if (!open) continue;
|
||||
const ticks = open[1]!.length;
|
||||
const close = lines.findIndex((l, j) => j > i && new RegExp(`^\`{${ticks},}\\s*$`).test(l));
|
||||
if (close < 0) break;
|
||||
out.push({ open: i, close, info: open[2]!.trim() });
|
||||
i = close;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `text` as a ``` block on its own lines, fenced longer than any run of backticks in it. */
|
||||
export function fenced(text: string, info: string): string {
|
||||
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
||||
const fence = "`".repeat(longest + 1);
|
||||
return `${fence}${info}\n${text.replace(/\r\n?/g, "\n").replace(/\s+$/, "")}\n${fence}`;
|
||||
}
|
||||
|
||||
export type FenceKind = "terminal" | "problems" | "trace" | "text" | "code";
|
||||
|
||||
/**
|
||||
* A block's kind and label: `terminal zsh` → a terminal named zsh, `problems`, `trace` (an
|
||||
* error's stack), `text` (a long paste); anything else is code in that language (`ts`).
|
||||
*/
|
||||
export function fenceLabel(info: string): { kind: FenceKind; label: string; language: string | null } {
|
||||
const [first = "", ...rest] = info.split(/\s+/);
|
||||
if (first === "terminal" || first === "problems" || first === "trace" || first === "text")
|
||||
return { kind: first, label: rest.join(" "), language: null };
|
||||
return { kind: "code", label: info, language: first || null };
|
||||
}
|
||||
|
||||
/** An error's stack trace or a crash: pasted as a block, its project lines as references. */
|
||||
export function looksLikeTrace(text: string): boolean {
|
||||
if (!text.trim().includes("\n")) return false;
|
||||
return [
|
||||
/^\s+at\s+\S/m, // JavaScript, Java, C#
|
||||
/^Traceback \(most recent call last\)/m,
|
||||
/^\s*File ".+", line \d+/m,
|
||||
/panicked at /,
|
||||
/^goroutine \d+ \[/m,
|
||||
/^\s*\d+: .+\n\s+at \S+:\d+/m, // Rust backtrace
|
||||
/^(?:Uncaught |Unhandled )?[\w.$]*(?:Error|Exception)\b.*\n\s+(?:at|File|in) /m,
|
||||
].some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/** Long pasted text goes in as a block rather than filling the box. */
|
||||
export const LONG_PASTE_LINES = 30;
|
||||
|
||||
/**
|
||||
* `path:line` places in output (`src/a.ts:12`, `/abs/a.go:34:5`, `(a.js:3:9)`,
|
||||
* `File "a.py", line 7`), each once, at most `limit`, dependency paths left out.
|
||||
*/
|
||||
export function placesInText(text: string, limit = 8): { path: string; line: number }[] {
|
||||
const out: { path: string; line: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (raw: string, line: string) => {
|
||||
const path = raw.replace(/^file:\/\//, "");
|
||||
if (/(^|[\\/])(node_modules|site-packages|\.cargo|\.rustup|go[\\/]pkg[\\/]mod)[\\/]/.test(path)) return;
|
||||
const key = `${path}:${line}`;
|
||||
if (seen.has(key) || out.length >= limit) return;
|
||||
seen.add(key);
|
||||
out.push({ path, line: Number(line) });
|
||||
};
|
||||
for (const m of text.matchAll(/File "([^"]+)", line (\d+)/g)) add(m[1]!, m[2]!);
|
||||
for (const m of text.matchAll(/(?:file:\/\/)?((?:[A-Za-z]:)?[\w.@+\-\\/]*[\w-]\.[A-Za-z0-9]{1,8}):(\d+)(?::\d+)?/g))
|
||||
add(m[1]!, m[2]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasted absolute paths, one per line (`/abs/a.rs`, `/abs/a.rs:10-20`, `/abs/a.rs:10:5`,
|
||||
* quoted, or `file://` URLs); `null` when any line is something else.
|
||||
*/
|
||||
export function pastedPaths(text: string): { path: string; lines?: { from: number; to: number } }[] | null {
|
||||
const rows = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (!rows.length) return null;
|
||||
const out: { path: string; lines?: { from: number; to: number } }[] = [];
|
||||
for (let row of rows) {
|
||||
row = row.replace(/^(["'])(.*)\1$/, "$2");
|
||||
if (row.startsWith("file://")) {
|
||||
try {
|
||||
row = decodeURIComponent(new URL(row).pathname).replace(/^\/([A-Za-z]:)/, "$1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const m = /^((?:\/|[A-Za-z]:[\\/]).*?)(?::(\d+)(?:(?:-(\d+))|(?::\d+))?)?$/.exec(row);
|
||||
if (!m) return null;
|
||||
const from = m[2] ? Number(m[2]) : undefined;
|
||||
out.push({
|
||||
path: m[1]!.replace(/[\\/]+$/, "") || m[1]!,
|
||||
...(from !== undefined && { lines: { from, to: m[3] ? Math.max(from, Number(m[3])) : from } }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
|
||||
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
|
||||
if (!lines) return `@${rel}`;
|
||||
@@ -156,7 +325,11 @@ export function toPrompt(draft: ComposerDraft): AgentPrompt {
|
||||
seen.add(f.path);
|
||||
return true;
|
||||
});
|
||||
return { text: draft.text.trim(), files: files.map((f) => f.path), images: draft.images };
|
||||
return {
|
||||
text: draft.text.trim(),
|
||||
files: files.map((f) => f.path),
|
||||
images: draft.images,
|
||||
};
|
||||
}
|
||||
|
||||
export const canSend = (d: ComposerDraft) => !!d.text.trim() || d.images.length > 0 || d.files.some((f) => f.attached);
|
||||
@@ -255,7 +428,11 @@ export function retryParts(r: AgentRetry, now: number): { attempt: string; wait:
|
||||
}
|
||||
|
||||
/** "167k/1M (17%)" of the context window and a tooltip with the exact counts. */
|
||||
export function usageLabel(u: AgentUsage): { short: string; percent: number; detail: string } {
|
||||
export function usageLabel(u: AgentUsage): {
|
||||
short: string;
|
||||
percent: number;
|
||||
detail: string;
|
||||
} {
|
||||
const percent = Math.min(100, Math.round((u.used / u.size) * 100));
|
||||
return {
|
||||
short: `${tokens(u.used)}/${tokens(u.size)} (${percent}%)`,
|
||||
@@ -278,9 +455,18 @@ export function limitLabel(l: AgentLimit, now: number): { short: string; percent
|
||||
const percent = Math.min(100, Math.round(l.usedPercent ?? 0));
|
||||
const name = known ? tx(known.name) : l.window;
|
||||
const used = tx`${name}: ${percent}% used`;
|
||||
if (l.resetsMs == null) return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: used };
|
||||
if (l.resetsMs == null)
|
||||
return {
|
||||
short: `${known?.short ?? l.window} ${percent}%`,
|
||||
percent,
|
||||
detail: used,
|
||||
};
|
||||
const reset = untilReset(Math.max(0, l.resetsMs - now));
|
||||
return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: tx`${used}, resets in ${reset}` };
|
||||
return {
|
||||
short: `${known?.short ?? l.window} ${percent}%`,
|
||||
percent,
|
||||
detail: tx`${used}, resets in ${reset}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** `45m`, `2h 51m`, `5d 3h`. */
|
||||
@@ -295,7 +481,10 @@ function untilReset(ms: number): string {
|
||||
/** "$0.42": what the session has cost so far. */
|
||||
export function costLabel(c: AgentCost): string {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: c.currency }).format(c.amount ?? 0);
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: c.currency,
|
||||
}).format(c.amount ?? 0);
|
||||
} catch {
|
||||
// Not an ISO 4217 code.
|
||||
return `${(c.amount ?? 0).toFixed(2)} ${c.currency}`;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Test helpers for the composer's editor (a CodeMirror view); plain text areas work too. */
|
||||
import { act, fireEvent } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
function viewOf(box: HTMLElement): EditorView {
|
||||
const view = EditorView.findFromDOM(box);
|
||||
if (!view) throw new Error("not a composer editor");
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Replaces the composer's text as typing would, caret at `caret` (the end by default). */
|
||||
export function typeIn(box: HTMLElement, value: string, caret = value.length) {
|
||||
if (box instanceof HTMLTextAreaElement) {
|
||||
fireEvent.change(box, {
|
||||
target: { value, selectionStart: caret, selectionEnd: caret },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const view = viewOf(box);
|
||||
act(() => {
|
||||
view.focus();
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
selection: { anchor: caret },
|
||||
userEvent: "input.type",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Types `chars` at the caret, one at a time. */
|
||||
export function typeChars(box: HTMLElement, chars: string) {
|
||||
const view = viewOf(box);
|
||||
act(() => view.focus());
|
||||
for (const ch of chars)
|
||||
act(() => {
|
||||
view.dispatch(view.state.replaceSelection(ch), {
|
||||
userEvent: "input.type",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const textOf = (box: HTMLElement) =>
|
||||
box instanceof HTMLTextAreaElement ? box.value : viewOf(box).state.doc.toString();
|
||||
|
||||
export const caretOf = (box: HTMLElement) => viewOf(box).state.selection.main.head;
|
||||
|
||||
export function setCaret(box: HTMLElement, anchor: number, head = anchor) {
|
||||
const view = viewOf(box);
|
||||
act(() => {
|
||||
view.focus();
|
||||
view.dispatch({ selection: { anchor, head } });
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import { useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import {
|
||||
registerAgentContextSource,
|
||||
registerEditorDropHandler,
|
||||
registerEditorKind,
|
||||
registerMenuGroup,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
TOOL_WINDOW,
|
||||
providerIcon,
|
||||
} from "./model";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { sendPaths, sendSelection, trackEditorCopies } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { closeActiveTask, closeAndNewTask, connect, disconnect, openHome, refreshProviders, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
@@ -77,6 +79,17 @@ export function activate(): () => void {
|
||||
sync(useProject.getState().project?.root);
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
trackEditorCopies(),
|
||||
registerAgentContextSource({
|
||||
id: "changes",
|
||||
title: "Changes",
|
||||
description: "Files with uncommitted changes",
|
||||
icon: "diff",
|
||||
resolve: async () => {
|
||||
const changed = await call(commands.vcsStatus());
|
||||
return changed.length ? { kind: "files", paths: changed.map((f) => f.path) } : null;
|
||||
},
|
||||
}),
|
||||
// The performance harness (`perf/`): long conversations to switch between and scroll.
|
||||
import.meta.env.DEV || import.meta.env.MODE === "perf"
|
||||
? registerActions([
|
||||
|
||||
@@ -3,10 +3,22 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn(), confirm: vi.fn() }));
|
||||
const ipc = vi.hoisted(() => ({
|
||||
rollback: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/workbench/messages", () => ({ confirm: ipc.confirm }));
|
||||
const entries: AgentEntry[] = [
|
||||
{ type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false },
|
||||
{
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
},
|
||||
{ type: "agent", text: "Original answer" },
|
||||
];
|
||||
|
||||
@@ -15,7 +27,10 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
commands: {
|
||||
agentEntries: async () => ({ status: "ok", data: entries }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
|
||||
agentGroupedChanges: async () => ({
|
||||
status: "ok",
|
||||
data: { uncommitted: [], commits: [] },
|
||||
}),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
agentRollback: ipc.rollback,
|
||||
agentPrompt: ipc.prompt,
|
||||
@@ -23,10 +38,13 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: () => Promise.resolve(() => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { composerDraft, retractFrom, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
@@ -92,7 +110,9 @@ it("keeps the transcript and running session when a fresh-session rerun is cance
|
||||
describe("retracting a turn", () => {
|
||||
it.each([0, 1])("stops before rollback and restores text/images without sending (turn %s)", async (turn) => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
updateComposer(task.id, { text: "Draft" });
|
||||
const running = retractFrom(task.id, turn, "Original", [image]);
|
||||
await waitFor(() => expect(ipc.cancel).toHaveBeenCalledTimes(1));
|
||||
@@ -101,14 +121,20 @@ describe("retracting a turn", () => {
|
||||
await running;
|
||||
expect(ipc.rollback).toHaveBeenCalledExactlyOnceWith(task.id, turn);
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
expect(composerDraft(task.id)).toEqual({ text: "Original\nDraft", images: [image], files: [] });
|
||||
expect(composerDraft(task.id)).toEqual({
|
||||
text: "Original\nDraft",
|
||||
images: [image],
|
||||
files: [],
|
||||
});
|
||||
expect(useAgents.getState().focus?.key).toBe(task.id);
|
||||
});
|
||||
|
||||
it("keeps the draft and avoids rollback when the agent does not stop in time", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
const result = expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("The agent is still stopping");
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await result;
|
||||
@@ -120,7 +146,10 @@ describe("retracting a turn", () => {
|
||||
});
|
||||
|
||||
it("recovers the original prompt when rollback fails", async () => {
|
||||
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
|
||||
ipc.rollback.mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Rollback failed" },
|
||||
});
|
||||
await expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("Rollback failed");
|
||||
expect(composerDraft(task.id).text).toBe("Original");
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
@@ -137,7 +166,9 @@ describe("retracting a turn", () => {
|
||||
);
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const user = userEvent.setup();
|
||||
const retract = screen.getAllByRole("button", { name: "Retract Message" })[0]!;
|
||||
const retract = screen.getAllByRole("button", {
|
||||
name: "Retract Message",
|
||||
})[0]!;
|
||||
await user.click(retract);
|
||||
await user.click(retract);
|
||||
expect(ipc.confirm).toHaveBeenCalledTimes(1);
|
||||
@@ -156,8 +187,12 @@ async function edit() {
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const box = screen.getAllByRole("textbox", { name: "Message" })[0]!;
|
||||
fireEvent.change(box, { target: { value: "Edited message" } });
|
||||
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
|
||||
typeIn(box, "Edited message");
|
||||
return {
|
||||
user,
|
||||
box,
|
||||
send: screen.getAllByRole("button", { name: "Send" })[0]!,
|
||||
};
|
||||
}
|
||||
|
||||
it.each([false, true])("recovers images after failed regeneration (new draft: %s)", async (draft) => {
|
||||
@@ -168,7 +203,13 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Original message", attachments: [image.name], images: [image], steered: false },
|
||||
{
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [image.name],
|
||||
images: [image],
|
||||
steered: false,
|
||||
},
|
||||
entries[1]!,
|
||||
],
|
||||
],
|
||||
@@ -185,8 +226,16 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
.run({ ctx: {}, arg: { task: task.id, turn: 0 } });
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
const files = draft ? [{ path: "/project/a.ts", rel: "a.ts" }] : [];
|
||||
if (draft) updateComposer(task.id, { text: "Another draft @a.ts", files, images: [added] });
|
||||
finish({ status: "error", error: { kind: "internal", message: "Send failed" } });
|
||||
if (draft)
|
||||
updateComposer(task.id, {
|
||||
text: "Another draft @a.ts",
|
||||
files,
|
||||
images: [added],
|
||||
});
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Send failed" },
|
||||
});
|
||||
await running;
|
||||
const expected = {
|
||||
text: draft ? "Original message\nAnother draft @a.ts" : "Original message",
|
||||
@@ -202,7 +251,14 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
|
||||
|
||||
describe("a sent message with images", () => {
|
||||
afterEach(() => {
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("shows the images and lists only the files as chips", async () => {
|
||||
@@ -220,7 +276,14 @@ describe("a sent message with images", () => {
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,AAA");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByText("shot.png")).not.toBeInTheDocument();
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("opens an image in a dialog and keeps the images when editing, minus the removed ones", async () => {
|
||||
@@ -250,7 +313,14 @@ describe("a sent message with images", () => {
|
||||
await user.click(screen.getAllByRole("button", { name: "Send" })[0]!);
|
||||
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
|
||||
expect(ipc.prompt.mock.calls[0]![1].images).toEqual([{ mimeType: "image/png", data: "BBB", name: "b.png" }]);
|
||||
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
|
||||
entries[0] = {
|
||||
type: "user",
|
||||
text: "Original message",
|
||||
attachments: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,7 +330,7 @@ describe("editing a sent agent message", () => {
|
||||
expect(send.textContent).toBe("");
|
||||
expect(send).toHaveAttribute("data-tooltip", "Send");
|
||||
expect(send).toBeEnabled();
|
||||
fireEvent.change(box, { target: { value: "" } });
|
||||
typeIn(box, "");
|
||||
expect(send).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -271,15 +341,18 @@ describe("editing a sent agent message", () => {
|
||||
fireEvent.keyDown(box, { key: "Enter", metaKey: true, isComposing: true });
|
||||
fireEvent.keyDown(box, { key: "Enter", metaKey: true, keyCode: 229 });
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(box).toHaveValue("Edited message");
|
||||
expect(textOf(box)).toBe("Edited message");
|
||||
});
|
||||
|
||||
it("keeps the draft and shows a rollback failure beside it", async () => {
|
||||
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
|
||||
ipc.rollback.mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Rollback failed" },
|
||||
});
|
||||
const { user, box, send } = await edit();
|
||||
await user.click(send);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Rollback failed");
|
||||
expect(box).toHaveValue("Edited message");
|
||||
expect(textOf(box)).toBe("Edited message");
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(send).toBeEnabled();
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
@@ -328,8 +401,13 @@ describe("editing a sent agent message", () => {
|
||||
act(() => updateComposer(task.id, { text: draft }));
|
||||
await user.click(send);
|
||||
expect(box).not.toBeInTheDocument();
|
||||
await act(async () => finish({ status: "error", error: { kind: "internal", message: "Send failed" } }));
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue(
|
||||
await act(async () =>
|
||||
finish({
|
||||
status: "error",
|
||||
error: { kind: "internal", message: "Send failed" },
|
||||
}),
|
||||
);
|
||||
expect(textOf(screen.getByRole("textbox", { name: "Message" }))).toBe(
|
||||
draft ? `Edited message\n${draft}` : "Edited message",
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
|
||||
@@ -346,7 +424,9 @@ describe("editing a sent agent message", () => {
|
||||
});
|
||||
|
||||
it("waits for a running turn to stop before rolling it back", async () => {
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
const { user, send } = await edit();
|
||||
await user.click(send);
|
||||
expect(ipc.cancel).toHaveBeenCalledWith(task.id, null);
|
||||
@@ -367,7 +447,7 @@ describe("editing a sent agent message", () => {
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(screen.getAllByRole("textbox", { name: "Message" })[0]).toHaveValue("Original message");
|
||||
expect(textOf(screen.getAllByRole("textbox", { name: "Message" })[0]!)).toBe("Original message");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -376,24 +456,34 @@ describe("session composer submission", () => {
|
||||
it.each(["click", "Enter"])("keeps an accessible icon-only Send action with %s submission", async (method) => {
|
||||
let finish!: (value: unknown) => void;
|
||||
ipc.prompt.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
render(
|
||||
<SessionEditor
|
||||
input={{
|
||||
kind: "agent-session",
|
||||
key: task.id,
|
||||
data: { task: task.id },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const send = screen.getByRole("button", { name: "Send" });
|
||||
expect(send.textContent).toBe("");
|
||||
expect(send).toHaveAttribute("data-tooltip", "Send");
|
||||
expect(send).toBeDisabled();
|
||||
const box = screen.getByRole("textbox", { name: "Message" });
|
||||
fireEvent.change(box, { target: { value: "Follow up" } });
|
||||
typeIn(box, "Follow up");
|
||||
expect(send).toBeEnabled();
|
||||
if (method === "click") fireEvent.click(send);
|
||||
else fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(ipc.prompt).toHaveBeenCalledExactlyOnceWith(task.id, { text: "Follow up", files: [], images: [] }, false);
|
||||
expect(send).toBeDisabled();
|
||||
await act(async () => finish({ status: "ok", data: null }));
|
||||
expect(box).toHaveValue("");
|
||||
expect(textOf(box)).toBe("");
|
||||
});
|
||||
|
||||
it("keeps Queue named and shows icon-only Stop actions while the agent works", async () => {
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
});
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const queue = screen.getByRole("button", { name: "Queue" });
|
||||
expect(queue.textContent).toBe("");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** "Send to Agent": the editor selection or Project view files as `@` references. */
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { activeEditor, useEditorRegistry } from "@/editor/api";
|
||||
import { noteCopySource } from "@/platform/clipboardSource";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { mentionOf } from "./composerModel";
|
||||
@@ -34,3 +35,25 @@ export function sendPaths(paths: readonly string[]): boolean {
|
||||
if (refs.length) addReferences(refs);
|
||||
return refs.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes the lines an editor's copy takes (the whole line when nothing is selected), so pasting
|
||||
* them into the composer references the file's lines.
|
||||
*/
|
||||
export function trackEditorCopies(): () => void {
|
||||
const onCopy = (e: ClipboardEvent) => {
|
||||
const handle = [...useEditorRegistry.getState().editors].find((h) =>
|
||||
h.view.contentDOM.contains(e.target as Node | null),
|
||||
);
|
||||
if (!handle || isVirtualUri(handle.ctx.path)) return;
|
||||
const { state } = handle.view;
|
||||
const sel = state.selection.main;
|
||||
const first = state.doc.lineAt(sel.from);
|
||||
// A selection ending at a line start does not include that line.
|
||||
const last = state.doc.lineAt(sel.to > sel.from && state.doc.lineAt(sel.to).from === sel.to ? sel.to - 1 : sel.to);
|
||||
const text = sel.empty ? first.text : state.sliceDoc(sel.from, sel.to);
|
||||
noteCopySource(text, { kind: "file", path: handle.ctx.path, from: first.number, to: last.number });
|
||||
};
|
||||
document.addEventListener("copy", onCopy, true);
|
||||
return () => document.removeEventListener("copy", onCopy, true);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import styles from "./Session.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
@@ -48,12 +49,19 @@ const backend = vi.hoisted(() => ({
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
fsProbe: async () => ({
|
||||
status: "ok",
|
||||
data: { size: 1, binary: false, tooLarge: false },
|
||||
}),
|
||||
agentEntries: async (id: string, sub: string | null) => {
|
||||
const key = sub ? `${id}/${sub}` : id;
|
||||
backend.fetched.push(key);
|
||||
await backend.entryWait;
|
||||
if (backend.entryError !== null)
|
||||
return { status: "error", error: { kind: "unavailable", message: backend.entryError } };
|
||||
return {
|
||||
status: "error",
|
||||
error: { kind: "unavailable", message: backend.entryError },
|
||||
};
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => {
|
||||
@@ -85,12 +93,22 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
backend.imported.push([provider, sessionId]);
|
||||
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
|
||||
if (!found) throw new Error("unknown session");
|
||||
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
|
||||
return {
|
||||
status: "ok",
|
||||
data: {
|
||||
...task("imported"),
|
||||
id: "imported",
|
||||
title: found.title,
|
||||
provider,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: () => Promise.resolve(() => {}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/messages")>()),
|
||||
@@ -139,7 +157,11 @@ const subagent: SubagentEntry = {
|
||||
endedMs: 1,
|
||||
cancellable: false,
|
||||
};
|
||||
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
|
||||
const input = (id: string) => ({
|
||||
kind: SESSION_KIND,
|
||||
key: sessionKey(id),
|
||||
data: { task: id },
|
||||
});
|
||||
const stream = () => document.querySelector<HTMLDivElement>(`.${styles.stream}`)!;
|
||||
const tool = (id: string, overrides: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
|
||||
type: "tool",
|
||||
@@ -182,7 +204,10 @@ beforeEach(() => {
|
||||
backend.imported = [];
|
||||
backend.externalError = null;
|
||||
backend.accepted = [];
|
||||
backend.acceptedOutcome = { accepted: ["very-long-file-name.ts"], skipped: [] };
|
||||
backend.acceptedOutcome = {
|
||||
accepted: ["very-long-file-name.ts"],
|
||||
skipped: [],
|
||||
};
|
||||
backend.confirmed = [];
|
||||
backend.confirmAnswer = true;
|
||||
backend.entries = new Map([
|
||||
@@ -203,7 +228,9 @@ beforeEach(() => {
|
||||
|
||||
describe("conversation rows", () => {
|
||||
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
useProject.setState({
|
||||
settings: { agents: { foldTurns: false } } as never,
|
||||
});
|
||||
const user = (text: string): AgentEntry => ({
|
||||
type: "user",
|
||||
text,
|
||||
@@ -229,8 +256,12 @@ describe("conversation rows", () => {
|
||||
const author = stream().querySelectorAll(`.${styles.agentAuthor}`)[1]!;
|
||||
expect(author.nextElementSibling).toHaveClass(styles.processRun!);
|
||||
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
|
||||
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
|
||||
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
|
||||
const quote = within(stream()).getAllByRole("button", {
|
||||
name: "Quote Reply",
|
||||
});
|
||||
const copy = within(stream()).getAllByRole("button", {
|
||||
name: "Copy Markdown",
|
||||
});
|
||||
// One per turn: only a turn's final reply carries actions.
|
||||
expect(quote).toHaveLength(2);
|
||||
for (const button of [...quote, ...copy]) {
|
||||
@@ -250,8 +281,18 @@ describe("conversation rows", () => {
|
||||
tool("two"),
|
||||
{ type: "agent", text: "More detail" },
|
||||
tool("List .", { status: "running" }),
|
||||
{ type: "permission", id: 1, title: "Run python3", command: "python3", options: [], chosen },
|
||||
{ type: "plan", items: [{ content: "Check files", status: "in_progress" }] },
|
||||
{
|
||||
type: "permission",
|
||||
id: 1,
|
||||
title: "Run python3",
|
||||
command: "python3",
|
||||
options: [],
|
||||
chosen,
|
||||
},
|
||||
{
|
||||
type: "plan",
|
||||
items: [{ content: "Check files", status: "in_progress" }],
|
||||
},
|
||||
{ type: "thought", text: "Consider the result" },
|
||||
{ type: "agent", text: "Done" },
|
||||
]);
|
||||
@@ -284,9 +325,18 @@ describe("conversation rows", () => {
|
||||
);
|
||||
|
||||
it("puts the time and hover actions under a turn's final reply only", async () => {
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
useProject.setState({
|
||||
settings: { agents: { foldTurns: false } } as never,
|
||||
});
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: 13000,
|
||||
},
|
||||
{ type: "agent", text: "First reply" },
|
||||
tool("read"),
|
||||
{ type: "agent", text: "Final reply" },
|
||||
@@ -349,7 +399,14 @@ describe("process folding", () => {
|
||||
describe("turn folding", () => {
|
||||
it("folds a finished turn's work under its message and keeps the reply", async () => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: 13000,
|
||||
},
|
||||
{ type: "thought", text: "think" },
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
@@ -371,7 +428,14 @@ describe("turn folding", () => {
|
||||
|
||||
it("leaves the turn the agent works on open", async () => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
|
||||
{
|
||||
type: "user",
|
||||
text: "Question",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1000,
|
||||
doneMs: null,
|
||||
},
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
]);
|
||||
@@ -404,7 +468,10 @@ describe("opened runs", () => {
|
||||
doneMs: null,
|
||||
} as const;
|
||||
const setTask = (status: AgentTask["status"]) => ({
|
||||
tasks: new Map(useAgents.getState().tasks).set("A", { ...useAgents.getState().tasks.get("A")!, status }),
|
||||
tasks: new Map(useAgents.getState().tasks).set("A", {
|
||||
...useAgents.getState().tasks.get("A")!,
|
||||
status,
|
||||
}),
|
||||
});
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [asked, tool("one"), tool("two"), { type: "agent", text: "Interim" }] as AgentEntry[]]]),
|
||||
@@ -458,8 +525,33 @@ describe("folded content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("message code blocks", () => {
|
||||
it("shows a terminal block as a card, folding long output", async () => {
|
||||
const output = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: `why?\n\`\`\`terminal zsh\n${output}\n\`\`\`\nthanks`,
|
||||
attachments: [],
|
||||
images: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
},
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await within(stream()).findByRole("group", { name: "Terminal · zsh" });
|
||||
expect(card).toHaveTextContent("20 lines");
|
||||
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(12);
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Show 8 more lines" }));
|
||||
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(20);
|
||||
expect(within(stream()).getByText(/thanks/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tags", () => {
|
||||
it("shows file references and skills as distinct tags", async () => {
|
||||
it("shows file references as chips that open the file, and skills as tags", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
@@ -473,8 +565,14 @@ describe("message tags", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("@src/a.ts")).toHaveClass(tagStyles.tag!);
|
||||
expect(screen.getByText("/triage")).toHaveClass(tagStyles.accent!);
|
||||
const chip = await within(stream()).findByRole("button", {
|
||||
name: "File reference a.ts",
|
||||
});
|
||||
expect(chip).toHaveTextContent("a.ts");
|
||||
expect(chip).toHaveAttribute("data-ref-path", "/project/src/a.ts");
|
||||
expect(within(stream()).getByText("/triage")).toHaveClass(chipStyles.command!);
|
||||
// Mentioned in the text, so not listed again under it.
|
||||
expect(within(stream()).getAllByText("a.ts")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,8 +612,14 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(el, { deltaY: -200 });
|
||||
@@ -523,16 +627,42 @@ describe("scrolling", () => {
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
height = 1100;
|
||||
const sent = { type: "user", text: "Next", attachments: [], steered: false, sentMs: 1, doneMs: null } as AgentEntry;
|
||||
const sent = {
|
||||
type: "user",
|
||||
text: "Next",
|
||||
attachments: [],
|
||||
steered: false,
|
||||
sentMs: 1,
|
||||
doneMs: null,
|
||||
} as AgentEntry;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [reply, sent]]]) }));
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "stays at the end below it", top: 300, bottom: 360, scrolled: 60, away: false },
|
||||
{ name: "keeps its header on screen", top: 50, bottom: 900, scrolled: 50, away: true },
|
||||
{ name: "back at the end without following", top: 300, bottom: 360, scrolled: 60, away: false, released: true },
|
||||
{
|
||||
name: "stays at the end below it",
|
||||
top: 300,
|
||||
bottom: 360,
|
||||
scrolled: 60,
|
||||
away: false,
|
||||
},
|
||||
{
|
||||
name: "keeps its header on screen",
|
||||
top: 50,
|
||||
bottom: 900,
|
||||
scrolled: 50,
|
||||
away: true,
|
||||
},
|
||||
{
|
||||
name: "back at the end without following",
|
||||
top: 300,
|
||||
bottom: 360,
|
||||
scrolled: 60,
|
||||
away: false,
|
||||
released: true,
|
||||
},
|
||||
])("scrolls a section opened at the end into view: $name", async ({ top, bottom, scrolled, away, released }) => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "thought", text: "Weighing it" },
|
||||
@@ -542,8 +672,14 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.getBoundingClientRect = () => ({ top: 0, bottom: 400 }) as DOMRect;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
@@ -575,13 +711,21 @@ describe("scrolling", () => {
|
||||
});
|
||||
|
||||
it("keeps following when the view grows taller and the browser pulls the offset back", () => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
let viewport = 400;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, get: () => viewport });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => viewport,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
// The composer shrank after sending: the end moved up and the offset was clamped to it.
|
||||
@@ -590,19 +734,31 @@ describe("scrolling", () => {
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
height = 1200;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(1200);
|
||||
});
|
||||
|
||||
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
|
||||
"releases following before an upward %s scroll, even within the bottom threshold",
|
||||
async (method) => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
|
||||
@@ -614,7 +770,11 @@ describe("scrolling", () => {
|
||||
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
|
||||
// A chunk can land before the browser dispatches the scroll event.
|
||||
height = 1004;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
@@ -623,7 +783,11 @@ describe("scrolling", () => {
|
||||
el.scrollTop = 592;
|
||||
fireEvent.scroll(el);
|
||||
height = 1008;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(592);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
// Scrolling down to the end resumes following.
|
||||
@@ -631,29 +795,49 @@ describe("scrolling", () => {
|
||||
el.scrollTop = 608;
|
||||
fireEvent.scroll(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not move the viewport while the scrollbar is held during streaming", () => {
|
||||
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerDown(el);
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(600);
|
||||
el.scrollTop = 588;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.pointerUp(window);
|
||||
height = 1200;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(588);
|
||||
});
|
||||
|
||||
@@ -664,15 +848,25 @@ describe("scrolling", () => {
|
||||
const el = stream();
|
||||
const output = screen.getByText("Tool output");
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
output.style.overflowY = "auto";
|
||||
output.scrollTop = 100;
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
fireEvent.wheel(output, { deltaY: -4 });
|
||||
height = 1100;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(1100);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
@@ -681,8 +875,14 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
@@ -691,7 +891,11 @@ describe("scrolling", () => {
|
||||
act(() => useAgents.setState({ entries: new Map([["A", []]]) }));
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
height = 1400;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]) }));
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]),
|
||||
}),
|
||||
);
|
||||
expect(el.scrollTop).toBe(1400);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
@@ -706,8 +910,14 @@ describe("scrolling", () => {
|
||||
</EditorShownProvider>,
|
||||
);
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 3000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 3000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
// Give the conversation a new tail while the scrollbar is held.
|
||||
fireEvent.pointerDown(el);
|
||||
act(() =>
|
||||
@@ -738,8 +948,14 @@ describe("scrolling", () => {
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => height,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
// The conversation grew after it was scrolled to the end, or WebKit moved the offset back
|
||||
@@ -761,8 +977,14 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
el.scrollTop = 600;
|
||||
fireEvent.scroll(el);
|
||||
el.scrollTop = 0;
|
||||
@@ -775,13 +997,22 @@ describe("scrolling", () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(el, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(el, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 400,
|
||||
});
|
||||
fireEvent.wheel(el);
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
|
||||
Object.defineProperty(el, "scrollTo", { configurable: true, value: vi.fn() });
|
||||
Object.defineProperty(el, "scrollTo", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
fireEvent.click(jump);
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
@@ -801,7 +1032,11 @@ describe("tool calls", () => {
|
||||
});
|
||||
|
||||
it("folds consecutive finished tool calls into one collapsed row", async () => {
|
||||
const edit = (id: string) => tool(id, { kind: "edit", diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }] });
|
||||
const edit = (id: string) =>
|
||||
tool(id, {
|
||||
kind: "edit",
|
||||
diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }],
|
||||
});
|
||||
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
@@ -822,7 +1057,11 @@ describe("a pending question", () => {
|
||||
id: 7,
|
||||
title: question,
|
||||
options: [
|
||||
{ id: "a", name: "Ship it as is and record it (Recommended)", kind: "allow_once" },
|
||||
{
|
||||
id: "a",
|
||||
name: "Ship it as is and record it (Recommended)",
|
||||
kind: "allow_once",
|
||||
},
|
||||
{ id: "b", name: "Detect the request in code", kind: "allow_once" },
|
||||
{ id: "c", name: "Skip", kind: "reject_once" },
|
||||
],
|
||||
@@ -833,7 +1072,9 @@ describe("a pending question", () => {
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", { name: "Allow tool call?" });
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "Allow tool call?",
|
||||
});
|
||||
expect(within(card).getByText(question.replaceAll("\n", " "))).toBeInTheDocument();
|
||||
expect(screen.getAllByText(question.replaceAll("\n", " "))).toHaveLength(1);
|
||||
const answers = within(card).getAllByRole("button");
|
||||
@@ -861,7 +1102,13 @@ describe("a pending AskUserQuestion", () => {
|
||||
header: answerKey,
|
||||
question: "Which?",
|
||||
multiSelect: false,
|
||||
options: [{ label: index === 0 ? "One" : "Two", description: "", preview: null }],
|
||||
options: [
|
||||
{
|
||||
label: index === 0 ? "One" : "Two",
|
||||
description: "",
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
]);
|
||||
@@ -891,8 +1138,16 @@ describe("a pending AskUserQuestion", () => {
|
||||
question: "Which layout?",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Table (Recommended)", description: "One row per check", preview: null },
|
||||
{ label: "List", description: "Grouped under headings", preview: null },
|
||||
{
|
||||
label: "Table (Recommended)",
|
||||
description: "One row per check",
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: "List",
|
||||
description: "Grouped under headings",
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -944,11 +1199,20 @@ describe("a pending AskUserQuestion", () => {
|
||||
fireEvent.click(within(card).getByRole("checkbox", { name: /^Disk/ }));
|
||||
fireEvent.click(within(card).getByRole("checkbox", { name: /^Other/ }));
|
||||
expect(submit).toHaveProperty("disabled", true);
|
||||
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), { target: { value: "GPU temps" } });
|
||||
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), {
|
||||
target: { value: "GPU temps" },
|
||||
});
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([
|
||||
["A", 9, JSON.stringify({ "Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"] })],
|
||||
[
|
||||
"A",
|
||||
9,
|
||||
JSON.stringify({
|
||||
"Which layout?": ["List"],
|
||||
"Which checks?": ["Disk", "GPU temps"],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1008,7 +1272,11 @@ describe("a pending permission request above the composer", () => {
|
||||
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
|
||||
const options = [
|
||||
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
|
||||
{ id: "opaque-policy", name: `Always Allow \`${command}\``, kind: "allow_always" },
|
||||
{
|
||||
id: "opaque-policy",
|
||||
name: `Always Allow \`${command}\``,
|
||||
kind: "allow_always",
|
||||
},
|
||||
{ id: "opaque-no", name: "Decline", kind: "reject_once" },
|
||||
{ id: "opaque-stop", name: "Decline and Stop", kind: "reject_always" },
|
||||
];
|
||||
@@ -1025,11 +1293,20 @@ describe("a pending permission request above the composer", () => {
|
||||
diffs: [],
|
||||
locations: [],
|
||||
},
|
||||
{ type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null },
|
||||
{
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
},
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(stream()).not.toContainElement(card);
|
||||
@@ -1048,36 +1325,58 @@ describe("a pending permission request above the composer", () => {
|
||||
});
|
||||
|
||||
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null };
|
||||
const pending: AgentEntry = {
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
};
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
const button = within(card).getAllByRole("button")[options.indexOf(option)]!;
|
||||
button.focus();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(backend.answers).toEqual([["A", 12, option.id]]));
|
||||
act(() =>
|
||||
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
|
||||
useAgents.setState({
|
||||
entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]),
|
||||
}),
|
||||
);
|
||||
expect(card).not.toBeInTheDocument();
|
||||
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
|
||||
const record = within(stream()).getByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
expect(record.querySelector("pre")).toBeNull();
|
||||
expect(within(record).queryByRole("button")).toBeNull();
|
||||
expect(record.textContent).toContain("python3 …");
|
||||
expect(record.textContent).not.toContain("write_text");
|
||||
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus());
|
||||
});
|
||||
|
||||
it("is answered with a number key when the focus isn't in a text field", async () => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null };
|
||||
const pending: AgentEntry = {
|
||||
type: "permission",
|
||||
id: 12,
|
||||
title: `Run ${command}`,
|
||||
command,
|
||||
options,
|
||||
chosen: null,
|
||||
};
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "Message" }), {
|
||||
key: "1",
|
||||
});
|
||||
expect(backend.answers).toEqual([]);
|
||||
const elsewhere = document.body.appendChild(document.createElement("div"));
|
||||
fireEvent.keyDown(elsewhere, { key: "1" });
|
||||
@@ -1103,12 +1402,20 @@ describe("a pending permission request above the composer", () => {
|
||||
},
|
||||
]);
|
||||
backend.entries.set("B", [
|
||||
{ type: "permission", id: 4, title: "The agent asks for permission", options: [], chosen: null },
|
||||
{
|
||||
type: "permission",
|
||||
id: 4,
|
||||
title: "The agent asks for permission",
|
||||
options: [],
|
||||
chosen: null,
|
||||
},
|
||||
]);
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
const { unmount } = render(<SessionEditor input={input("A")} />);
|
||||
const line = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
const line = await screen.findByRole("region", {
|
||||
name: "codex wants to run a command",
|
||||
});
|
||||
const answers = within(line.querySelector<HTMLElement>(`.${styles.permissionAnswers}`)!).getAllByRole("button");
|
||||
expect(answers.map((b) => b.textContent)).toEqual(["Allow Once", "Decline"]);
|
||||
fireEvent.click(answers[0]!);
|
||||
@@ -1116,7 +1423,9 @@ describe("a pending permission request above the composer", () => {
|
||||
unmount();
|
||||
|
||||
render(<SessionEditor input={input("B")} />);
|
||||
const card = await screen.findByRole("region", { name: "Allow tool call?" });
|
||||
const card = await screen.findByRole("region", {
|
||||
name: "Allow tool call?",
|
||||
});
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([
|
||||
@@ -1177,7 +1486,9 @@ describe("conversation loading", () => {
|
||||
});
|
||||
|
||||
it("keeps subagent failures separate and retries that subagent", async () => {
|
||||
useAgents.setState({ entries: new Map([["A", backend.entries.get("A")!]]) });
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", backend.entries.get("A")!]]),
|
||||
});
|
||||
backend.entryError = "No such subagent";
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
|
||||
@@ -1251,7 +1562,9 @@ describe("session state across tasks and hosts", () => {
|
||||
|
||||
it("opens a long conversation at its end and renders the earlier entries after it", async () => {
|
||||
const entries = Array.from({ length: 150 }, (_, i): AgentEntry => ({ type: "agent", text: `Reply ${i}` }));
|
||||
useAgents.setState({ entries: new Map(useAgents.getState().entries).set("A", entries) });
|
||||
useAgents.setState({
|
||||
entries: new Map(useAgents.getState().entries).set("A", entries),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(screen.getByText("Reply 149")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Reply 0")).toBeInTheDocument();
|
||||
@@ -1300,7 +1613,7 @@ describe("session state across tasks and hosts", () => {
|
||||
const markVisible = vi.spyOn(await import("./store"), "markVisible");
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
const message = screen.getByRole("textbox", { name: "Message" });
|
||||
fireEvent.change(message, { target: { value: "Draft to keep" } });
|
||||
typeIn(message, "Draft to keep");
|
||||
const conversation = stream();
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
|
||||
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
|
||||
@@ -1315,7 +1628,7 @@ describe("session state across tasks and hosts", () => {
|
||||
expect(markVisible).toHaveBeenCalledWith("A");
|
||||
expect(useAgents.getState().unread.has("A")).toBe(false);
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
|
||||
expect(message).toHaveValue("Draft to keep");
|
||||
expect(textOf(message)).toBe("Draft to keep");
|
||||
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
|
||||
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
|
||||
});
|
||||
@@ -1341,7 +1654,10 @@ describe("session state across tasks and hosts", () => {
|
||||
fireEvent.wheel(stream());
|
||||
stream().scrollTop = stick ? height - viewport : 230;
|
||||
fireEvent.scroll(stream());
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({
|
||||
top: stick ? 1600 : 230,
|
||||
stick,
|
||||
});
|
||||
view.unmount();
|
||||
viewport = 150;
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
@@ -1404,24 +1720,39 @@ describe("meters in the task header", () => {
|
||||
it("shows the provider's usage limits by default and each meter as Settings › Tools › Agents › Chat allows", async () => {
|
||||
const reported: AgentTask = {
|
||||
...task("A"),
|
||||
usage: { used: 24_000, size: 200_000, cost: { amount: 0.42, currency: "USD" } },
|
||||
usage: {
|
||||
used: 24_000,
|
||||
size: 200_000,
|
||||
cost: { amount: 0.42, currency: "USD" },
|
||||
},
|
||||
};
|
||||
const limits = [
|
||||
{ window: "five_hour", usedPercent: 19, resetsMs: null },
|
||||
{ window: "seven_day", usedPercent: 9.4, resetsMs: null },
|
||||
];
|
||||
useAgents.setState({ tasks: new Map([["A", reported]]), limits: new Map([["codex", limits]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", reported]]),
|
||||
limits: new Map([["codex", limits]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
const usage = screen.getByLabelText(/^Context /);
|
||||
expect(usage).toHaveTextContent("24k/200k (12%)");
|
||||
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({ width: "12%" });
|
||||
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({
|
||||
width: "12%",
|
||||
});
|
||||
expect(screen.getByText("5h 19%")).toBeInTheDocument();
|
||||
expect(screen.getByText("7d 9%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.42")).toBeNull();
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
settings: { agents: { showContextUsage: false, showUsageLimits: false, showCost: true } },
|
||||
settings: {
|
||||
agents: {
|
||||
showContextUsage: false,
|
||||
showUsageLimits: false,
|
||||
showCost: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("24k/200k (12%)")).toBeNull();
|
||||
@@ -1440,8 +1771,20 @@ describe("sessions from other apps", () => {
|
||||
|
||||
it("lists external sessions in the history popup and imports one as a task", async () => {
|
||||
backend.external = [
|
||||
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
|
||||
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
|
||||
{
|
||||
provider: "claude",
|
||||
sessionId: "abc",
|
||||
title: "Login fix",
|
||||
cwd: "/work/app",
|
||||
updatedMs: Date.now(),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "thread-1",
|
||||
title: "Add tests",
|
||||
cwd: null,
|
||||
updatedMs: 0,
|
||||
},
|
||||
];
|
||||
await openHistory();
|
||||
expect(screen.getByText("Login fix")).toBeInTheDocument();
|
||||
@@ -1484,7 +1827,17 @@ describe("changes refresh", () => {
|
||||
vi.useFakeTimers();
|
||||
useChangesOptions.setState({ groupByCommit: true });
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree", cwd: "/worktrees/a", projectRoot: "/project" }]]),
|
||||
tasks: new Map([
|
||||
[
|
||||
"A",
|
||||
{
|
||||
...task("A"),
|
||||
isolation: "worktree",
|
||||
cwd: "/worktrees/a",
|
||||
projectRoot: "/project",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await act(async () => {});
|
||||
@@ -1578,12 +1931,17 @@ describe("grouped changes", () => {
|
||||
});
|
||||
const worktreeTask = () => {
|
||||
useChangesOptions.setState({ groupByCommit: true });
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]),
|
||||
});
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
};
|
||||
|
||||
it("puts committed files under their commit and rolls back only the uncommitted ones", async () => {
|
||||
backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] };
|
||||
backend.grouped = {
|
||||
uncommitted: [change("a.ts")],
|
||||
commits: [commit({ files: [change("b.ts")] })],
|
||||
};
|
||||
worktreeTask();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
|
||||
@@ -1620,10 +1978,15 @@ describe("grouped changes", () => {
|
||||
});
|
||||
|
||||
it("accepts the task's committed work too, not only the uncommitted files", async () => {
|
||||
backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] };
|
||||
backend.grouped = {
|
||||
uncommitted: [],
|
||||
commits: [commit({ files: [change("b.ts")] })],
|
||||
};
|
||||
backend.changes = [change("b.ts")];
|
||||
worktreeTask();
|
||||
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) });
|
||||
useAgents.setState({
|
||||
tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Committed");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import type { VirtualElement } from "@floating-ui/react-dom";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
const TEXT_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textIndent",
|
||||
"textAlign",
|
||||
"textTransform",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
/** A textarea has no text DOM to measure; mirror its wrapping and scroll offset. */
|
||||
function caretRect(textarea: HTMLTextAreaElement, caret: number): DOMRect {
|
||||
const computed = getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
mirror.className = styles.caretMirror ?? "";
|
||||
for (const property of TEXT_STYLES) mirror.style[property] = computed[property];
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
mirror.textContent = textarea.value.slice(0, caret);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = textarea.value.slice(caret) || "\u200b";
|
||||
mirror.append(marker);
|
||||
document.body.append(mirror);
|
||||
try {
|
||||
const box = textarea.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.5;
|
||||
return new DOMRect(
|
||||
box.left + textarea.clientLeft + marker.offsetLeft - textarea.scrollLeft,
|
||||
box.top + textarea.clientTop + marker.offsetTop - textarea.scrollTop,
|
||||
0,
|
||||
lineHeight,
|
||||
);
|
||||
} finally {
|
||||
mirror.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompletionAnchor(
|
||||
ref: RefObject<HTMLTextAreaElement | null>,
|
||||
caret: number | null,
|
||||
text: string,
|
||||
): VirtualElement | null {
|
||||
const [anchor, setAnchor] = useState<VirtualElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const textarea = ref.current;
|
||||
if (!textarea || caret === null) {
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
const update = () =>
|
||||
setAnchor({ contextElement: textarea, getBoundingClientRect: () => caretRect(textarea, caret) });
|
||||
update();
|
||||
textarea.addEventListener("scroll", update);
|
||||
return () => textarea.removeEventListener("scroll", update);
|
||||
}, [ref, caret, text]);
|
||||
return anchor;
|
||||
}
|
||||
@@ -5,9 +5,15 @@
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerMenuGroup, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import {
|
||||
registerAgentContextSource,
|
||||
registerMenuGroup,
|
||||
registerOverlay,
|
||||
registerStatusItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore";
|
||||
import { clearProblems, installProblemsEvents, problemsReport, useProblems } from "./problemsStore";
|
||||
import { installProblemDecorations } from "./decorations";
|
||||
import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView";
|
||||
import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView";
|
||||
@@ -45,6 +51,17 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
unsubProject,
|
||||
installProblemsEvents(),
|
||||
registerAgentContextSource({
|
||||
id: "problems",
|
||||
title: "Problems",
|
||||
description: "The project's errors and warnings",
|
||||
icon: "problems",
|
||||
resolve: async () => {
|
||||
const root = useProject.getState().project?.root;
|
||||
const text = root ? problemsReport(useProblems.getState().map, root) : null;
|
||||
return text ? { kind: "block", info: "problems", text } : null;
|
||||
},
|
||||
}),
|
||||
reportProblems(),
|
||||
installProblemDecorations(),
|
||||
installServerEvents(),
|
||||
@@ -77,6 +94,7 @@ export function activate(): () => void {
|
||||
icon: "toolwindow-usages",
|
||||
anchor: "bottom",
|
||||
stripe: "onUse",
|
||||
sessionContent: true,
|
||||
order: 35,
|
||||
component: UsagesView,
|
||||
headerContent: UsagesTabs,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fileDiagnostics,
|
||||
filePaths,
|
||||
mergeDiagnostics,
|
||||
problemsReport,
|
||||
profiledProblems,
|
||||
projectCounts,
|
||||
useProblems,
|
||||
@@ -134,3 +135,19 @@ describe("filePaths", () => {
|
||||
expect(filePaths(map, "/p/none.py")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("problemsReport", () => {
|
||||
it("lists errors before warnings with their places, leaving out hints, up to a limit", () => {
|
||||
let map = mergeDiagnostics(new Map(), "/p/src/a.ts", "tsc", [d(3, "warning", "ts"), d(0, "hint")]);
|
||||
map = mergeDiagnostics(map, "/p/src/b.ts", "tsc", [d(9, "error"), d(1, "error")]);
|
||||
expect(problemsReport(map, "/p")).toBe(
|
||||
[
|
||||
"src/b.ts:2:1: error: line 1 (tsc)",
|
||||
"src/b.ts:10:1: error: line 9 (tsc)",
|
||||
"src/a.ts:4:1: warning: line 3 (ts)",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(problemsReport(map, "/p", 1)).toBe("src/b.ts:2:1: error: line 1 (tsc)\n… 2 more");
|
||||
expect(problemsReport(mergeDiagnostics(new Map(), "/p/a", "x", [d(0, "info")]), "/p")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -188,3 +188,25 @@ export function installProblemsEvents(): () => void {
|
||||
offProfile();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors and warnings as `rel:line:col: severity: message (source)` lines, errors first, at most
|
||||
* `max`; null when there are none. For an agent's context.
|
||||
*/
|
||||
export function problemsReport(map: ProblemsMap, root: string, max = 60): string | null {
|
||||
const rows: { severity: Severity; text: string }[] = [];
|
||||
for (const path of map.keys()) {
|
||||
const rel = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
||||
for (const d of fileDiagnostics(map, path)) {
|
||||
if (d.severity !== "error" && d.severity !== "warning") continue;
|
||||
const at = `${rel}:${d.range.start.line + 1}:${d.range.start.col + 1}`;
|
||||
const source = d.source ? ` (${d.source})` : "";
|
||||
rows.push({ severity: d.severity, text: `${at}: ${d.severity}: ${d.message.split("\n")[0]}${source}` });
|
||||
}
|
||||
}
|
||||
if (!rows.length) return null;
|
||||
rows.sort((a, b) => Number(a.severity !== "error") - Number(b.severity !== "error"));
|
||||
const shown = rows.slice(0, max).map((r) => r.text);
|
||||
if (rows.length > max) shown.push(`… ${rows.length - max} more`);
|
||||
return shown.join("\n");
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user