Compare commits

...
Author SHA1 Message Date
l0ng-ai 89f00a29f9 feat(agents): reference chips, quote blocks and a richer completion in the composer (#3091)
The agent composer is now a CodeMirror editor: the text stays plain and is what's sent, while @ file references are drawn as chips (icon, name, lines) that move and delete as one piece, open on click and preview on hover; Backspace after a chip opens it as text. Quotes show as blocks, and sent messages get the same chips and quote blocks. The @ completion lists open editors first, highlights matches and shows the folder; the focused editor's file is offered above the text, a pasted project path becomes a reference, and dropping files shows a hint. Lines copied from an editor paste as a reference, terminal output as a terminal block, and code blocks show as cards. The reference trimming regex avoids lookbehind for WebKit before 16.4.
2026-11-13 22:29:59 +08:00
l0ng-ai 834f9e172c test(i18n): allow CLI to stay untranslated (#3186)
The New CLI tab added "CLI" to the text catalogs; like Shell and other abbreviations it reads the same in every locale, so the catalog test lists it with them.
2026-11-13 22:27:11 +08:00
l0ng-ai 45badba915 feat(agents): generated titles for Claude Code and Codex tasks (#3133)
Claude Code run over stdio never generates a session title, and codex app-server leaves thread naming to its clients, so both kept the first line of their prompt as their title. After a turn without a title, the Claude shim sends the generate_session_title control request (persist: true) with the last 1000 characters of the prompts and replies; the Codex shim asks thread/sample for a title after the first completed turn of an unnamed top-level thread and stores it with thread/name/set. Without a title the next turn tries again, and a task renamed in the IDE keeps its name. Imported Codex sessions take their name from ~/.codex/session_index.jsonl.
2026-11-13 22:25:10 +08:00
l0ng-ai 71728a473a ci(release): keep the Rust build cache when a release build fails (#3125)
A release build that fails after compiling (for example when notarization is rejected) saved no Rust cache, so a rerun compiled the whole app again. The rust-cache steps of the desktop and server builds now set cache-on-failure.
2026-11-13 22:23:12 +08:00
l0ng-ai 2acd5cf479 fix(java): import a Gradle build on a JDK its Gradle runs on when no version is declared (#3181)
Without a Project JDK and without a Java version the build declares, the jdtls Gradle import ran on jdtls's own runtime, which an older Gradle cannot run on, so every non-JDK dependency stayed unresolved. When the Gradle version is known, the import now uses the newest installed JDK that Gradle supports; a Project JDK, Gradle JVM or declared toolchain still wins.

Fixes #2681
2026-11-13 22:17:50 +08:00
Vadim LazukandInf1mum 85f4ffb67c feat(terminal): CLI sessions as editor-area tabs (#3177)
Full-size agent CLIs (Claude Code, Codex, aider…) open as editor-area tabs next to file tabs. Each tab runs one saved command in the user's login shell in the project directory and reuses the terminal session (PTY lifecycle, resize, IME, links, theme, font zoom); tabs keep their session while hidden. A New CLI tab (action cli.new, also in the Terminal tool window's more menu) lists saved CLIs, adds new ones and opens a plain shell. CLI tabs are restored with the editor session showing a Start button and never run on their own. A new optional, generic EditorKindContribution.isBusy hook makes closing a running CLI ask first and keeps busy tabs out of tab-limit eviction.

Fixes #2202

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 22:12:54 +08:00
Muuandmuutot 4348dab26e fix(settings): write settings off the main thread (#3153)
On Windows a WebView2 IPC callback reaches settings_set with about 750 KiB of the main thread's 1 MiB stack already used, while validating the merged Settings needs about 250 KiB in a debug build (measured by bisecting a worker's stack size; independent of the settings file), so applying a setting could overflow the stack. The write now runs on a worker thread that the command joins, keeping writes serialized as before.

Co-authored-by: muutot <[email protected]>
2026-11-13 22:07:12 +08:00
l0ng-ai 38d4dd8c16 feat(deployment): enter an FTP server's password in its connection settings (#3179)
FTP and FTPS deployment servers had no password field: the password was only asked for after a failed Test Connection. The Connection tab now has Password and Save password below User name, staged like a setting and written on Apply (saved, or for this session); clearing Save password forgets a saved password, and a saved one shows as "Password already stored". Test Connection logs in with a typed, not yet applied password.

Fixes #3151
2026-11-13 21:57:57 +08:00
Vadim LazukandInf1mum 58af3c55a5 fix(git-toolbox): show the focused file's branch in workspace windows (#3159)
In a workspace window the plugin asked vcs_status of the window's project, the workspace container, which is usually not a git repository, so the branch item disappeared and Fetch Now fetched nothing. Git Toolbox now resolves the editor-focused file to the deepest open project containing it and shows that repository, falling back to the window's project, the one shown last, then the first open one. Fetch Now fetches only the repository the status bar shows. Covered by plugin unit tests and a host e2e test with a two-member workspace.

Fixes #2556

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 21:46:42 +08:00
Muuandmuutot a8643366c8 fix(vcs): keep the inline blame at the line start, off the caret path, and its tooltip to itself (#3167)
The inline blame label's gap was a margin, so a caret at the line end was drawn after the label, and a blank line pushed both label and caret past column 0. The gap is now padding (clipped from hit-testing past any code-vision hint), blank lines get no gap, and the commit tooltip opens only when the pointer is on the label itself, not on another line-end widget at the same offset. The label sits after plugin line-end messages (side 2) and is no longer truncated.

Co-authored-by: muutot <[email protected]>
2026-11-13 21:43:57 +08:00
rohitkumardandaandrohitdanda 22685815cc fix(agents): keep the user's MCP server values in the credential store (#3002)
Environment variable and header values of the user's MCP servers move from the user settings.toml into the credential store (AgentSecrets, as for agent environment values): on start-up, on settings import/export, and when the MCP page saves. They are filled back in when the page lists servers and when a session starts. A project's own servers keep their values in the project's .openidea/settings.toml, where the project shares them.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 21:28:11 +08:00
Vadim LazukandInf1mum e6b88b2701 feat(editor): support linked editing for matching HTML tags (#2993)
Editing the name in an opening or closing HTML tag renames the matching tag of the same element as you type, from the @lezer/html syntax tree, in one undoable transaction. Covers HTML, Vue, Angular templates, JSP, Django and Astro (ERB is token-based and stays out). Smart Keys gains Simultaneous <tag></tag> editing (on by default). Review fix: the Gutter Icons bookmarks group keeps its real gutter class.

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 21:21:12 +08:00
l0ng-ai c076dedefc feat(agents): search the task list (#3174)
A search field above the Agents task list (and the session history popup) filters tasks as you type: fuzzy on the title with the matches highlighted, word starts on the project folder and branch. It searches every section, collapsed Archived included, hides sections without a match; Up/Down move the selection, Enter opens, Escape clears.
2026-11-13 21:14:41 +08:00
l0ng-ai 3764d963d7 feat(editor): show the interfaces a Go type or method implements in the gutter (#3173)
Go types satisfy interfaces implicitly, so the gutter only showed the interface side. Named types and receiver methods now get an implements marker from gopls's implementation answer (the interfaces / interface methods they satisfy). Named non-struct types no longer show the interfaces they satisfy as subclasses. Go method targets are named after their receiver and Go interface/struct declarations count as enclosing types.
2026-11-13 21:10:36 +08:00
l0ng-ai 4d2bcbbc26 fix(debugger): offer the Kotlin debugger before starting a Maven or Gradle debug run (#3172)
Debugging a test in a module with Kotlin sources started Maven / Gradle with a JDWP agent and only found the missing kotlin-debug-adapter when attaching: the Run window flashed, the empty Debug window replaced it and the build was stopped. debugWithAgent now asks the debugger (debug.jvmAttachReady) first; when the adapter is missing the build doesn't start and Install and Debug installs it and debugs again.
2026-11-13 21:03:00 +08:00
l0ng-ai 56d9c8908c fix(status bar): close Background Tasks when its last task ends (#3171)
Cancelling the only task from the Background Tasks popup unmounted the status bar widget it was anchored to, leaving the popup in the top-left corner reading "No background tasks". It now closes when the task list empties, and anchors bottom-right when there are no tasks.
2026-11-13 21:02:19 +08:00
l0ng-ai 0bd0c4e03b fix(toolwindows): don't reopen Run, Debug and Find with the project (#3170)
Run and Debug sessions and Find / Find Usages results don't survive closing the project, but a layout saved with one of these windows open brought it back empty on reopen ("Not debugging"). Tool windows can declare sessionContent; their visibility is left out of the saved layout and dropped from older layouts.
2026-11-13 21:01:43 +08:00
l0ng-ai 209fc796ce feat(debugger): attach to .NET processes and list processes on Windows (#3169)
Attach to Process lists .NET apps (dotnet host or apphost with <name>.runtimeconfig.json) under ".NET (netcoredbg)" and attaches by process id. On Windows the process list is now implemented (ToolHelp + image path + command line with limited query access), so the action no longer fails as unsupported. On macOS the executable path comes from proc_pidpath, since ps comm kept relative launch paths and hid apphosts and Go binaries started as ./bin/app.
2026-11-13 20:50:10 +08:00
l0ng-ai e7e89e46c2 docs: Chinese README, sponsor section only in the Chinese one (#3168) 2026-11-13 20:46:18 +08:00
l0ng-ai c87ab87417 feat(vcs): create a Git repository for a project that has none (#3166)
Git › Create Git Repository… asks for the folder (the project root by default), runs git init and rescans for repositories, so the branch widget, Commit tool window and file statuses appear right away. The title bar's Version Control menu for projects without a repository lists it first.
2026-11-13 20:39:03 +08:00
l0ng-ai a9ad1f1bbb fix(agents): stop Claude Code processes when their connection closes (#3163)
Closing a task's connection (idle release, archive, delete) stopped its claude -p processes through a weak reference to the in-process shim, which the closing connection could already have dropped; the processes then ran until the app quit. The close watcher now holds the shim, and a session dropped without being stopped stops its process. Rollback and fork, which start a stopped agent without a turn, now schedule the same idle release.
2026-11-13 20:35:41 +08:00
l0ng-ai 4c8caca6a5 feat(editor): open the blame annotations from Code Vision's author hint (#3161)
Clicking a declaration's Code Vision author hint now runs Annotate with Git Blame for that editor and leaves the annotations open when already shown.
2026-11-13 20:25:47 +08:00
l0ng-ai 2ec2e75d40 fix(vcs): run the merge editor's buttons on a mouse click on macOS (#3160)
WebKit doesn't focus a clicked button, so focus moved to the editor area outside the merge editor and cleared the focused merge editor; Apply, Cancel, Accept Yours/Theirs and the toolbar actions then did nothing on a mouse click. The merge editor root is now focusable (tabindex -1) so a click inside it keeps focus there.
2026-11-13 20:20:50 +08:00
Muuandmuutot 04d5d9bd58 feat(color scheme): edit text style for errors and warnings (#3154)
Errors and Warnings attributes (Error, Warning, Typo, Unused symbol, Deprecated symbol) now offer bold, italic and effect choices like syntax tokens, written to --wave-*/--fg-* variables the editor reads; Typo and Deprecated symbol are listed and previewed. Deprecated symbol keeps the text colour and draws only its effect; an effect set to none draws none. Custom and imported schemes keep the font style and effect of token-less attributes (Errors and Warnings, Git), and the preview shows them.

Co-authored-by: muutot <[email protected]>
2026-11-13 20:15:01 +08:00
l0ng-ai 9a415e6875 fix(project-tree): list a re-installed SNAPSHOT's entries after reloading the project (#3158)
A SNAPSHOT installed again replaces its jar in place, which no file watcher reports, so expanded folders inside it under External Libraries kept the old entries. The project view now re-reads its loaded folders inside archives when project roots change (a build tool sync).
2026-11-13 20:04:17 +08:00
l0ng-ai 6f83f8cad0 fix(java): show a re-installed SNAPSHOT's classes after reloading the project (#3157)
A SNAPSHOT installed again replaces its jar in place, so class URIs and jdtls' class handles stay the same; the backend's virtual document cache, the editor's cache and jdtls' decompiler cache all kept the old contents. Cached library classes now record their archive's modification time and are read again once it changes; after a build tool sync jdtls refreshes external archives, closes the changed roots and drops its decompiled sources (openidea.java.refreshArchives, java-usages 1.14.0), and open classes whose jar changed are replaced. The editor drops cached virtual documents when project roots change.
2026-11-13 20:01:02 +08:00
144 changed files with 7443 additions and 984 deletions

No files matched your search

+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
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
View File
@@ -4288,6 +4288,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+4 -13
View File
@@ -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>
&nbsp;·&nbsp;
@@ -19,8 +23,6 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -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).
+88
View File
@@ -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>
&nbsp;·&nbsp;
<a href="https://openidea.sh/zh">文档</a>
&nbsp;·&nbsp;
<a href="#性能对比">性能对比</a>
&nbsp;·&nbsp;
<a href="#插件">插件</a>
&nbsp;·&nbsp;
<a href="#社区">社区</a>
&nbsp;·&nbsp;
<a href="#赞助">赞助</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
为核心的原生桌面应用里。
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
提供 macOS、Windows 和 Linux 版本。
## 性能对比
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
取 5 次运行的中位数:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| --------------------- | -------------- | ---------------------- |
| 显示项目窗口 | **0.9 s** | 3.1 s |
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
| 退出 | **0.3 s** | 5.9 s |
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
## 插件
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
## 社区
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
- 微信群,扫码加入:
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
## 赞助
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
</tr>
</table>
## 许可证
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
+43 -3
View File
@@ -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]
+4
View File
@@ -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");
+57 -10
View File
@@ -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 {
+76 -4
View File
@@ -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 =
+43 -4
View File
@@ -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();
+50 -5
View File
@@ -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 {
+21
View File
@@ -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() });
+3
View File
@@ -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,
+3
View File
@@ -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
View File
@@ -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()));
+11
View File
@@ -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,
+1
View File
@@ -180,6 +180,7 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
+103 -7
View File
@@ -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()
+41 -1
View File
@@ -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.
+9 -3
View File
@@ -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
});
}
+2 -1
View File
@@ -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)
}
+133
View File
@@ -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");
}
+76
View File
@@ -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(
+1 -1
View File
@@ -183,7 +183,7 @@ async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
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");
+15
View File
@@ -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.
+43 -2
View File
@@ -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
}
+37
View File
@@ -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(
+17 -2
View File
@@ -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
+21 -1
View File
@@ -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]
+1 -1
View File
@@ -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"),
)
}
+5
View File
@@ -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) {
+43 -14
View File
@@ -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");
+17
View File
@@ -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.");
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.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.
+3
View File
@@ -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);
}
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
d6991b9065d8b623efdf9c03fb92b4875e95e666b3dadb3925ff5ad327f5fdcb
+111 -30
View File
@@ -1,6 +1,6 @@
//! Git Toolbox for OpenIDEA: background fetch of every open project's
//! repository (on the plugin's `on-schedule`, which also fires after the
//! instance was reclaimed while idle), and the focused 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");
}
+29 -5
View File
@@ -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
+2 -1
View File
@@ -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
}
+1
View File
@@ -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,
+7
View File
@@ -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]
+6 -2
View File
@@ -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,
@@ -9605,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,
+168
View File
@@ -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();
}
});
});
+271
View File
@@ -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>");
});
});
});
+84
View File
@@ -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,
},
];
});
+14
View File
@@ -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 = [];
+61 -21
View File
@@ -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;
+6 -2
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { registerActions } from "@/platform/actions";
import { call, commands, isCancelled, nextRequestId, 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();
};
}
+13
View File
@@ -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) =>
+8 -1
View File
@@ -9,7 +9,7 @@ import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { executeAction } 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;
+16 -3
View File
@@ -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))",
+24 -5
View File
@@ -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;
+63
View File
@@ -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(
{
+53 -2
View File
@@ -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);
+4
View File
@@ -180,6 +180,10 @@
display: flex;
flex-direction: column;
}
.taskSearch {
flex: none;
margin: 6px 8px 4px;
}
.history {
display: flex;
flex-direction: column;
+26
View File
@@ -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();
+70 -15
View File
@@ -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>
+122 -57
View File
@@ -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);
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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 {
+1 -15
View File
@@ -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));
}
+401
View File
@@ -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>
);
}
+18 -3
View File
@@ -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;
+47 -30
View File
@@ -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 },
),
);
+97 -8
View File
@@ -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"]);
+101 -77
View File
@@ -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();
+245
View File
@@ -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];
}
+336 -92
View File
@@ -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"]);
});
});
+201 -12
View File
@@ -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}`;
+53
View File
@@ -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 } });
});
}
+14 -1
View File
@@ -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([
+119 -29
View File
@@ -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("");
+24 -1
View File
@@ -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);
}
+457 -94
View File
@@ -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");
+14
View File
@@ -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;
}
+20 -2
View File
@@ -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");
}
+13 -1
View File
@@ -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 });
+21 -1
View File
@@ -28,7 +28,13 @@ import {
import { removeAllSpecial, specialAt, toggleFieldAt, toggleMethodAt } from "./breakpoints/special";
import { fieldAt, methodAt } from "./breakpoints/symbols";
import { toggleBreakpointSmart } from "./breakpoints/toggle";
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
import {
isJavaAttachTarget,
isJavaDebuggable,
javaAttachConfig,
javaLaunchConfig,
jvmAttachDebuggerReady,
} from "./java";
import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap";
import { refreshMemory, useMemory } from "./memory/memoryState";
import { closeInstances, instancesOutdated, showInstances, showReferrers, useInstances } from "./memory/instancesState";
@@ -478,6 +484,20 @@ export function registerDebugActions(): () => void {
})(),
),
},
{
id: "debug.jvmAttachReady",
title: "Check the JVM Debugger",
category: "Debug",
hidden: true,
description:
"Whether the debugger for a build tool's JVMs is installed, offering the install when not (arg: { module, retry, done(ready) })",
run: ({ arg }) => {
const a = arg as { module: string; retry: () => void; done: (ready: boolean) => void };
const root = useProject.getState().project?.root;
if (!root) return a.done(true);
void jvmAttachDebuggerReady(a.module, root, a.retry).then(a.done, () => a.done(true));
},
},
{
id: "debug.stop",
title: "Stop",
+4 -1
View File
@@ -10,11 +10,12 @@ export const KIND_LABELS: Record<AttachKind, string> = {
java: "Java (JDWP)",
node: "Node.js",
python: "Python (debugpy)",
dotnet: ".NET (netcoredbg)",
go: "Go (Delve)",
native: "Native (LLDB)",
};
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "go", "native"];
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "dotnet", "go", "native"];
export type AttachRow =
| { kind: "group"; key: string; label: string; count: number }
@@ -84,6 +85,8 @@ export async function attachConfig(p: AttachableProcess, root: string): Promise<
// (`--pid` injection ships only an x86_64 helper on macOS.)
p.port != null ? { debugServer: p.port, justMyCode: skip } : { processId: p.pid, justMyCode: skip },
);
case "dotnet":
return base(p, root, { processId: p.pid, justMyCode: skip });
case "go":
return base(p, root, { mode: "local", processId: p.pid });
case "native":
+1
View File
@@ -73,6 +73,7 @@ export function activate(): () => void {
icon: "toolwindow-debug",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 20,
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
+23
View File
@@ -80,6 +80,29 @@ export async function moduleHasKotlinSources(cwd: string): Promise<boolean> {
return false;
}
/**
* Whether the debugger for the JVMs of `module` is installed. Maven / Gradle start those JVMs
* before the debugger attaches, so a missing Kotlin debugger is offered here, before the build
* runs: "Install and Debug" installs it and calls `retry`.
*/
export async function jvmAttachDebuggerReady(module: string, root: string, retry: () => void): Promise<boolean> {
if (!(await moduleHasKotlinSources(module ? join(root, module) : root))) return true;
const tools = await call(commands.toolsList()).catch(() => []);
if (tools.find((t) => t.name === KOTLIN_ADAPTER)?.status !== "missing") return true;
notifications().notify({
group: "run",
level: "warning",
key: "debug.start",
title: "The Kotlin debugger is not installed",
body: "This module has Kotlin sources, so debugging it needs the Kotlin debugger (kotlin-debug-adapter).",
sticky: true,
actions: [
{ label: "Install and Debug", run: () => void executeAction("tools.install", KOTLIN_ADAPTER).then(retry) },
],
});
return false;
}
/** java-debug missing from jdtls: offer the one-click install (jdtls restarts with the bundle). */
function offerInstall(e: unknown): never {
if (e instanceof IpcError && e.kind === "unavailable" && /java-debug|debugger/i.test(e.message)) {
+2
View File
@@ -34,6 +34,7 @@ import {
initProjectTree,
leadSelection,
reloadAll,
reloadArchiveDirs,
reloadLoadedDirs,
setChangedFiles,
setOpenFiles,
@@ -253,6 +254,7 @@ export function activate(): () => void {
initProjectTypes(),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)),
onIdeEvent("projectRootsChanged", () => void reloadArchiveDirs()),
];
return () => {
disposers.reverse().forEach((d) => d());
+23
View File
@@ -3,6 +3,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
import { visibleInfo } from "./model";
import {
applyFsChange,
reloadArchiveDirs,
reloadLoadedDirs,
currentModel,
expandAll,
@@ -114,6 +115,28 @@ describe("refresh of excluded folders", () => {
});
});
describe("refresh of library archives", () => {
it("re-reads the loaded folders inside archives only", async () => {
await fs.createFile("/m2/lib.jar!/p/A.class");
await loadDir(ROOT);
await loadDir("/m2/lib.jar!");
await loadDir("/m2/lib.jar!/p");
await fs.createFile("/m2/lib.jar!/p/B.class");
const spy = vi.spyOn(fs, "readDir");
await reloadArchiveDirs();
expect(spy.mock.calls.map(([p]) => p).sort()).toEqual(["/m2/lib.jar!", "/m2/lib.jar!/p"]);
expect(
useProjectTree
.getState()
.listings.get("/m2/lib.jar!/p")
?.map((e) => e.name),
).toEqual(["A.class", "B.class"]);
spy.mockRestore();
});
});
describe("refresh on ignore-rule changes", () => {
it("refreshes loaded descendants once, preserving selection and unrelated folders", async () => {
await fs.createFile("/p/docs/sub/note.md");
+10
View File
@@ -565,6 +565,16 @@ export async function reloadLoadedDirs(dirs: readonly string[]) {
await Promise.all(dirs.filter((p) => listings.has(p)).map((p) => loadDir(p, true)));
}
/**
* Re-reads the loaded folders inside archives (`<archive>!…`): a build tool sync may have replaced
* a library jar in place (a SNAPSHOT installed again), which no file watcher reports.
*/
export async function reloadArchiveDirs() {
const { listings } = get();
const inArchive = (p: string) => p.endsWith("!") || p.includes("!/") || p.includes("!\\");
await Promise.all([...listings.keys()].filter(inArchive).map((p) => loadDir(p, true)));
}
/** Re-reads every loaded folder (View › Reload from Disk). */
export async function reloadAll() {
const { listings } = get();
+1
View File
@@ -381,6 +381,7 @@ export function activate(): () => void {
icon: "toolwindow-run",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 5,
component: lazyComponent(() => import("./RunView").then((m) => m.RunView)),
headerContent: lazyComponent(() => import("./RunView").then((m) => m.RunTabs)),
+18 -1
View File
@@ -4,6 +4,8 @@ import type { RunConfig } from "@/platform/ipc";
const h = vi.hoisted(() => ({
attached: [] as number[],
onLine: null as ((line: string) => void) | null,
ready: true,
retry: null as (() => void) | null,
}));
vi.mock("@/platform/ipc", async (orig) => {
const ipc = await orig<typeof import("@/platform/ipc")>();
@@ -15,8 +17,12 @@ vi.mock("@/platform/ipc", async (orig) => {
});
vi.mock("@/platform/actions", async (orig) => ({
...(await orig<typeof import("@/platform/actions")>()),
executeAction: async (id: string, arg: { port: number }) => {
executeAction: async (id: string, arg: { port: number; retry: () => void; done: (ready: boolean) => void }) => {
if (id === "debug.attach") h.attached.push(arg.port);
if (id === "debug.jvmAttachReady") {
h.retry = arg.retry;
arg.done(h.ready);
}
return true;
},
}));
@@ -44,6 +50,8 @@ const mavenTest: RunConfig = {
beforeEach(() => {
h.attached = [];
h.onLine = null;
h.ready = true;
h.retry = null;
});
describe("Maven test debugging", () => {
@@ -66,4 +74,13 @@ describe("Maven test debugging", () => {
h.onLine!("Listening for transport dt_socket at address: 50123");
expect(h.attached).toEqual([50123]);
});
it("doesn't start the build while the debugger is missing; its install debugs again", async () => {
h.ready = false;
expect(await debugWithAgent(mavenTest)).toBe(true);
expect(h.onLine).toBeNull();
h.ready = true;
h.retry!();
await vi.waitFor(() => expect(h.onLine).not.toBeNull());
});
});
+20
View File
@@ -150,11 +150,31 @@ function needsPinnedPort(config: RunConfig): boolean {
return config.isTest || config.args.some(isMavenTestGoal);
}
/**
* Whether the debugger can attach to the JVMs `config` starts. When it isn't installed, the
* debugger offers the install (then debugs again) and the build isn't started for nothing.
*/
function attachDebuggerReady(config: RunConfig, show?: boolean): Promise<boolean> {
return new Promise((resolve) => {
executeAction("debug.jvmAttachReady", {
module: config.module ?? config.cwd ?? "",
retry: () => void debugWithAgent(config, show),
done: resolve,
}).then(
(ran) => {
if (!ran) resolve(true);
},
() => resolve(true),
);
});
}
/** Runs `config` with a JDWP agent and attaches the Java debugger to every JVM it starts. */
export async function debugWithAgent(config: RunConfig, show?: boolean): Promise<boolean> {
const pinned = needsPinnedPort(config) ? await call(commands.debugFreePort()).catch(() => null) : null;
const agent = jdwpLaunch(config, pinned ?? undefined);
if (!agent) return false;
if (!(await attachDebuggerReady(config, show))) return true;
const launch = await withGradleAttachListener(agent);
let sessionId: string | null = null;
const waiting: number[] = [];
+1
View File
@@ -185,6 +185,7 @@ export function activate(): void | (() => void) {
icon: "toolwindow-find",
anchor: "bottom",
stripe: "onUse",
sessionContent: true,
order: 5,
component: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindToolWindow)),
headerContent: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindTabs)),
@@ -0,0 +1,87 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
import { validateSettings } from "../model/schema";
import { applySettings, openSettings, useSettingsDialog } from "../model/staging";
import { DEPLOYMENT_PAGES } from "../pages/deployment";
const mocks = vi.hoisted(() => ({ store: vi.fn(), saved: vi.fn(), test: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const actual = await original<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
sshSetSecret: mocks.store,
sshPasswordSaved: mocks.saved,
deployTestConnection: mocks.test,
},
};
});
const option = DEPLOYMENT_PAGES[0]!.sections![0]!.options[0]!;
if (option.kind !== "custom") throw new Error("Deployment editor missing");
const Editor = option.component;
const savedDialog = useSettingsDialog.getState();
const savedProject = useProject.getState();
const PAGE = DEPLOYMENT_PAGES[0]!.id;
beforeEach(() => {
vi.resetAllMocks();
mocks.store.mockResolvedValue(undefined);
mocks.saved.mockResolvedValue(false);
mocks.test.mockResolvedValue({ status: "ok", data: null });
});
afterEach(() => {
cleanup();
useSettingsDialog.setState(savedDialog, true);
useProject.setState(savedProject, true);
});
function mount() {
const server = {
...DEFAULT_DEPLOYMENT_SERVER,
id: "srv",
name: "Web",
kind: "ftp",
ftp: { host: "ftp.example.com", port: null, user: "web", passive: true },
};
const settings = validateSettings({ deployment: { servers: [server] } });
useProject.setState({ settings, project: null });
openSettings(PAGE);
return render(
<PageContext.Provider value={{ pageId: PAGE, scope: "global" }}>
<Editor />
</PageContext.Provider>,
);
}
describe("FTP deployment password", () => {
it("tests the connection with the typed password and saves it on Apply", async () => {
mount();
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "s3cret" } });
fireEvent.click(screen.getByText("Test Connection"));
await waitFor(() => expect(mocks.test).toHaveBeenCalled());
expect(mocks.test.mock.calls[0]![2]).toBe("s3cret");
expect(mocks.store).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText("Save password"));
expect(await applySettings()).toBe(true);
expect(mocks.store).toHaveBeenCalledWith("ftp:srv", "s3cret", true);
});
it("shows a stored password and forgets it when Save password is cleared", async () => {
mocks.saved.mockResolvedValue(true);
mount();
await waitFor(() =>
expect(screen.getByLabelText("Password").getAttribute("placeholder")).toBe("Password already stored"),
);
const save = screen.getByLabelText("Save password") as HTMLInputElement;
expect(save.checked).toBe(true);
fireEvent.click(save);
expect(await applySettings()).toBe(true);
expect(mocks.store).toHaveBeenCalledWith("ftp:srv", null, false);
});
});
+73 -7
View File
@@ -5,7 +5,7 @@
*/
import { useEffect, useId, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { call, commands, IpcError, type FsConnection, type RemoteEntry } from "@/platform/ipc";
import { call, commands, IpcError, isTauri, type FsConnection, type RemoteEntry } from "@/platform/ipc";
import { useTx, tx, txTemplate } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import {
@@ -33,7 +33,7 @@ import {
} from "@/ui";
import { GlobListEditor, Row } from "../components/Form";
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
import { useSetting } from "../components/context";
import { usePageContext, useSetting, useSettingsFormFor } from "../components/context";
import {
addMapping,
fixDefaultServer,
@@ -291,10 +291,52 @@ function TextField({
);
}
function FtpRows({ ftp, update }: { ftp: FtpConnectionValid; update: (patch: Partial<FtpConnectionValid>) => void }) {
interface FtpPassword {
password: string;
save: boolean;
}
/** The FTP password, staged like a setting and written to the saved passwords on Apply. */
function useFtpPassword(secretKey: string, ftp: boolean) {
const page = usePageContext();
const form = useSettingsFormFor(page.pageId, page.scope);
const [known, setKnown] = useState<{ key: string; saved: boolean } | null>(null);
const saved = known?.key === secretKey && known.saved;
useEffect(() => {
let live = true;
if (ftp && isTauri())
void commands.sshPasswordSaved(secretKey).then(
(stored) => live && setKnown({ key: secretKey, saved: stored }),
() => {},
);
return () => {
live = false;
};
}, [secretKey, ftp]);
const base: FtpPassword = { password: "", save: saved };
const value = form.useExternal<FtpPassword>(`ftpPassword.${secretKey}`, base, async ({ password, save }) => {
if (password) await commands.sshSetSecret(secretKey, password, save);
else if (!save) await commands.sshSetSecret(secretKey, null, false);
const stored = password ? save : save && saved;
setKnown({ key: secretKey, saved: stored });
return { password: "", save: stored };
});
return { ...value, saved };
}
function FtpRows({
ftp,
secret,
update,
}: {
ftp: FtpConnectionValid;
secret: ReturnType<typeof useFtpPassword>;
update: (patch: Partial<FtpConnectionValid>) => void;
}) {
const hostId = useId();
const portId = useId();
const userId = useId();
const passwordId = useId();
const tx = useTx();
// What is typed (e.g. an invalid port); the editor is keyed by server.
const [portText, setPortText] = useState(ftp.port?.toString() ?? "");
@@ -335,6 +377,24 @@ function FtpRows({ ftp, update }: { ftp: FtpConnectionValid; update: (patch: Par
onChange={(e) => update({ user: e.target.value })}
/>
</Row>
<Row label="Password" htmlFor={passwordId}>
<Input
id={passwordId}
type="password"
autoComplete="off"
className={styles.wide}
value={secret.value.password}
placeholder={secret.saved && !secret.value.password ? tx("Password already stored") : undefined}
onChange={(e) => secret.set({ ...secret.value, password: e.target.value })}
/>
</Row>
<div className={styles.option}>
<Checkbox
checked={secret.value.save}
onChange={(save) => secret.set({ ...secret.value, save })}
label="Save password"
/>
</div>
<div className={styles.option}>
<Checkbox checked={ftp.passive} onChange={(passive) => update({ passive })} label="Passive mode" />
</div>
@@ -357,7 +417,11 @@ function ConnectionTab({
const [browsing, setBrowsing] = useState(false);
const [result, setTest] = useState<TestState | null>(null);
const connection = deploymentConnection(server);
const testKey = JSON.stringify([server.kind, server.sshConfig, server.ftp, server.rootPath]);
const ftp = server.kind === "ftp" || server.kind === "ftps";
const secret = useFtpPassword(deploymentSecretKey(server), ftp);
// A password typed but not applied yet is what Test Connection logs in with.
const typedPassword = (ftp && secret.value.password) || null;
const testKey = JSON.stringify([server.kind, server.sshConfig, server.ftp, server.rootPath, typedPassword]);
const test = result?.ssh === testKey ? result : null;
const rootId = useId();
@@ -365,7 +429,7 @@ function ConnectionTab({
const ssh = testKey;
setTest({ ssh, kind: "busy" });
try {
await call(commands.deployTestConnection(connection, server.rootPath.trim() || "~"));
await call(commands.deployTestConnection(connection, server.rootPath.trim() || "~", typedPassword));
setTest({ ssh, kind: "ok", count: 0 });
} catch (e) {
setTest({ ssh, kind: "error", error: e });
@@ -396,8 +460,8 @@ function ConnectionTab({
</PageLink>
</Row>
)}
{(server.kind === "ftp" || server.kind === "ftps") && (
<FtpRows ftp={server.ftp} update={(patch) => update({ ftp: { ...server.ftp, ...patch } })} />
{ftp && (
<FtpRows ftp={server.ftp} secret={secret} update={(patch) => update({ ftp: { ...server.ftp, ...patch } })} />
)}
<Row label="Root path" htmlFor={rootId} hint="Mappings' deployment paths are relative to this folder.">
<span className={styles.field}>
@@ -886,6 +950,8 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
"SSH configuration",
"FTP",
"Passive mode",
"Password",
"Save password",
"Test Connection",
"Use as Default",
"Server Group",
@@ -10,6 +10,7 @@ import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride }
import {
ATTRIBUTES,
SCHEMES,
EFFECT_CSS,
overrideDeclarations,
effectiveScheme,
resolvedSchemeTokens,
@@ -123,6 +124,8 @@ function previewVars(
k.startsWith("--border-") ||
k.startsWith("--ansi-") ||
k.startsWith("--git-") ||
k.startsWith("--wave-") ||
k.startsWith("--editor-") ||
k === "--caret"
)
vars[k] = v;
@@ -137,20 +140,32 @@ function previewVars(
/** Text styled like an attribute in the preview. */
function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
if (!attr?.token)
return {
const base = attr?.fg ?? attr?.effect;
if (!attr?.token) {
const colour: CSSProperties = {
...(attr?.fg ? { color: `var(${attr.fg})` } : null),
...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null),
// Token-less attributes with a font-style renderer carry it on `--<fg>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
...(attr?.fontStyle && (attr.fg ?? attr.effect)
? {
fontWeight: `var(${attr.fg ?? attr.effect}-bold, inherit)`,
fontStyle: `var(${attr.fg ?? attr.effect}-italic, inherit)`,
textDecoration: `var(${attr.effect ?? attr.fg}-effect, none)`,
}
};
// Token-less attributes with a font-style renderer carry it on `--<fg|effect>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
if (!attr?.fontStyle || !base) return colour;
const effectColor = `var(${attr.effect ?? attr.fg ?? "--fg-muted"})`;
const effect: TextAttributesOverride["effect"] = attr.defaultEffect ?? (attr.effect ? "wave" : undefined);
const decoration =
effect && effect !== "none"
? `var(${attr.effect ?? base}-effect, ${EFFECT_CSS[effect]?.(effectColor) ?? "none"})`
: undefined;
return {
...colour,
fontWeight: `var(${base}-bold, ${
attr.defaultBold ? "var(--fw-code-bold, 700)" : "inherit"
})` as CSSProperties["fontWeight"],
fontStyle: `var(${base}-italic, ${attr.defaultItalic ? "italic" : "inherit"})`,
...(decoration
? { textDecoration: decoration, ...({ WebkitTextDecoration: decoration } as CSSProperties) }
: null),
};
}
if (attr.italicOnly)
return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` };
const t = attr.token;
@@ -206,6 +221,11 @@ const SAMPLES: Record<string, CodeSample> = {
[" ", "text"],
["legacyCall", "unused"],
],
[
["typo", "typo"],
[" ", "text"],
["deprecatedCall", "deprecated"],
],
[
["bad\u203dchar", "invalid"],
[" \u00b7 ", "whitespaces"],
@@ -701,6 +701,7 @@ const SMART_KEYS = editorPage({
{ t: "check", key: `${P}.smartKeys.pairQuotes`, label: "Insert pair quote" },
{ t: "check", key: `${P}.smartKeys.surroundSelection`, label: "Surround selection on typing quote or brace" },
{ t: "check", key: `${P}.smartKeys.tabOutOfPair`, label: "Jump outside closing bracket/quote with Tab" },
{ t: "check", key: `${P}.smartKeys.simultaneousTagEditing`, label: "Simultaneous <tag></tag> editing" },
{
t: "select",
key: `${P}.smartKeys.reformatOnPaste`,
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more