дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-13 21:25:47 +02:00
Порівняти коміти
52
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
0e92c629ae | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f | ||
|
|
1392e824bb | ||
|
|
6073f35eb6 | ||
|
|
b02964962b | ||
|
|
9ca8097f3c | ||
|
|
1b08d5de09 | ||
|
|
8faedce175 | ||
|
|
c8bdfa8edd | ||
|
|
4d22632609 | ||
|
|
7f61b7b8ab | ||
|
|
abe30914b5 | ||
|
|
1ae3edd5c8 | ||
|
|
52b8cf5e70 | ||
|
|
258ba189c6 | ||
|
|
f3d0417f4c | ||
|
|
291d1324c5 | ||
|
|
a6e137a6ae | ||
|
|
89f00a29f9 | ||
|
|
834f9e172c | ||
|
|
45badba915 | ||
|
|
71728a473a | ||
|
|
2acd5cf479 | ||
|
|
85f4ffb67c | ||
|
|
4348dab26e | ||
|
|
38d4dd8c16 | ||
|
|
58af3c55a5 | ||
|
|
a8643366c8 | ||
|
|
22685815cc | ||
|
|
e6b88b2701 | ||
|
|
c076dedefc | ||
|
|
3764d963d7 | ||
|
|
4d2bcbbc26 | ||
|
|
56d9c8908c | ||
|
|
0bd0c4e03b | ||
|
|
209fc796ce | ||
|
|
e7e89e46c2 | ||
|
|
c87ab87417 | ||
|
|
a9ad1f1bbb | ||
|
|
4c8caca6a5 | ||
|
|
2ec2e75d40 | ||
|
|
04d5d9bd58 | ||
|
|
9a415e6875 | ||
|
|
6f83f8cad0 | ||
|
|
b971c8b391 | ||
|
|
c333990404 |
No files matched your search
@@ -78,7 +78,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
with: { key: "${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
Створено
@@ -4288,6 +4288,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-13
@@ -9,6 +9,10 @@
|
||||
The IDE you know, rebuilt in Rust.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
@@ -19,8 +23,6 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<a href="#sponsor">Sponsor</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -70,17 +72,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Sponsor
|
||||
|
||||
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
|
||||
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
你熟悉的 IDE,用 Rust 重新打造。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<a href="#赞助">赞助</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
|
||||
为核心的原生桌面应用里。
|
||||
|
||||
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
|
||||
提供 macOS、Windows 和 Linux 版本。
|
||||
|
||||
## 性能对比
|
||||
|
||||
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
|
||||
取 5 次运行的中位数:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| --------------------- | -------------- | ---------------------- |
|
||||
| 显示项目窗口 | **0.9 s** | 3.1 s |
|
||||
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
|
||||
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
|
||||
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
|
||||
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
|
||||
| 退出 | **0.3 s** | 5.9 s |
|
||||
|
||||
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
|
||||
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
|
||||
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
|
||||
|
||||
## 插件
|
||||
|
||||
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
|
||||
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
|
||||
|
||||
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
|
||||
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
|
||||
|
||||
## 社区
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
|
||||
- 微信群,扫码加入:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
|
||||
|
||||
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
|
||||
|
||||
## 赞助
|
||||
|
||||
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
|
||||
|
||||
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
|
||||
@@ -3,11 +3,13 @@
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
|
||||
//! `<home>/.codex/session_index.jsonl`).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -206,11 +208,39 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
|
||||
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
|
||||
/// so the last one wins.
|
||||
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
|
||||
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
|
||||
let mut names = HashMap::new();
|
||||
for line in std::io::BufReader::new(file).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
|
||||
let id = v.get("id").and_then(serde_json::Value::as_str);
|
||||
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
if let (Some(id), Some(name)) = (id, name) {
|
||||
names.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
|
||||
/// thread names, else by their first prompts.
|
||||
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
|
||||
let mut sessions: Vec<ExternalSession> =
|
||||
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
|
||||
if !sessions.is_empty() {
|
||||
let names = codex_names(codex_home);
|
||||
for s in &mut sessions {
|
||||
if let Some(name) = names.get(&s.session_id) {
|
||||
s.title = short(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -282,6 +312,16 @@ mod tests {
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
|
||||
write(
|
||||
&tmp.path().join("session_index.jsonl"),
|
||||
&[
|
||||
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
|
||||
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
|
||||
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
|
||||
],
|
||||
);
|
||||
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1097,6 +1097,8 @@ impl AgentManager {
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
// Copying may have started the source's agent: it stops again once idle.
|
||||
source.release_when_idle();
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), task.clone());
|
||||
task.persist();
|
||||
@@ -1166,6 +1168,8 @@ impl AgentManager {
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
// Undoing may have started the agent: it stops again once idle.
|
||||
task.release_when_idle();
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,42 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Claude Code titles a session from the last 1000 characters of its text.
|
||||
const TITLE_SOURCE_CHARS: usize = 1000;
|
||||
/// Below this Claude Code gives no title without asking the model.
|
||||
const TITLE_SOURCE_MIN_CHARS: usize = 10;
|
||||
|
||||
/// The conversation's own text to title the session after: the prompts and the replies,
|
||||
/// without the files attached to the prompts. `None` while there is too little.
|
||||
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
for e in chain(entries, None) {
|
||||
if !(is_turn_start(e) || kind(e) == "assistant") {
|
||||
continue;
|
||||
}
|
||||
let blocks = match e.pointer("/message/content") {
|
||||
Some(Value::String(s)) => vec![s.as_str()],
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !t.starts_with("\n<context ref="))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for block in blocks {
|
||||
text.push_str(block);
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
let text = text.trim();
|
||||
let len = text.chars().count();
|
||||
if len < TITLE_SOURCE_MIN_CHARS {
|
||||
return None;
|
||||
}
|
||||
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -219,6 +255,29 @@ mod tests {
|
||||
assert_eq!(cut(&c, 1), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_come_from_prompts_and_replies() {
|
||||
let mut prompt = user("u1", None, "Fix the login form");
|
||||
prompt["message"]["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
|
||||
let entries = vec![
|
||||
prompt,
|
||||
assistant(
|
||||
"a1",
|
||||
"u1",
|
||||
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
|
||||
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
|
||||
),
|
||||
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
|
||||
];
|
||||
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
|
||||
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
|
||||
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
|
||||
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_names() {
|
||||
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
|
||||
|
||||
@@ -191,6 +191,8 @@ struct State {
|
||||
limit_resets_ms: Option<f64>,
|
||||
terminal_only: Vec<String>,
|
||||
titled: bool,
|
||||
/// A title is being read or generated.
|
||||
titling: bool,
|
||||
}
|
||||
|
||||
struct Turn {
|
||||
@@ -365,6 +367,7 @@ impl Shim {
|
||||
limit_resets_ms: None,
|
||||
terminal_only: Vec::new(),
|
||||
titled: false,
|
||||
titling: false,
|
||||
}),
|
||||
cwd,
|
||||
starting: tokio::sync::Mutex::new(()),
|
||||
@@ -583,11 +586,12 @@ impl Shim {
|
||||
impl super::Shim for Shim {
|
||||
fn attach(&self, client: Peer) {
|
||||
let _ = self.client.set(client.clone());
|
||||
// The client went away: so do the processes.
|
||||
let weak = self.me.clone();
|
||||
// The client went away: so do the processes. Held until then: the connection drops
|
||||
// its handler (this shim) as it closes, and the sessions would go without stopping.
|
||||
let me = self.me.upgrade();
|
||||
tokio::spawn(async move {
|
||||
client.closed().await;
|
||||
if let Some(me) = weak.upgrade() {
|
||||
if let Some(me) = me {
|
||||
me.closed.store(true, Ordering::Release);
|
||||
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||
for s in sessions {
|
||||
@@ -598,6 +602,15 @@ impl super::Shim for Shim {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
/// A session dropped without being stopped (the shim went away) takes its process along.
|
||||
fn drop(&mut self) {
|
||||
if let Some(p) = self.st.get_mut().proc.take() {
|
||||
p.kill.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn shim(&self) -> Option<Arc<Shim>> {
|
||||
self.shim.upgrade()
|
||||
@@ -1048,7 +1061,8 @@ impl Session {
|
||||
} else if matches!(st.start, Start::New { .. }) {
|
||||
st.start = Start::Resume { id: self.id.clone(), at: None };
|
||||
}
|
||||
read_title = !st.titled;
|
||||
read_title = !st.titled && !st.titling;
|
||||
st.titling |= read_title;
|
||||
if let Some(turn) = st.turn.as_ref()
|
||||
&& belongs(turn, &msg)
|
||||
{
|
||||
@@ -1073,13 +1087,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the title Claude Code gave the session, once it has one.
|
||||
/// Sends the session's title once it has one: the one in its transcript, or else one
|
||||
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
|
||||
/// Without a title the next turn tries again.
|
||||
fn read_title(self: &Arc<Self>) {
|
||||
let me = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Some(title) = chain_title(&me) else { return };
|
||||
me.st.lock().titled = true;
|
||||
if let Some(shim) = me.shim() {
|
||||
tokio::spawn(async move {
|
||||
let reader = me.clone();
|
||||
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
|
||||
let mut title = entries.as_deref().and_then(history::title);
|
||||
if title.is_none()
|
||||
&& let Some(source) = entries.as_deref().and_then(history::title_source)
|
||||
{
|
||||
title = me.generate_title(source).await;
|
||||
}
|
||||
{
|
||||
let mut st = me.st.lock();
|
||||
st.titling = false;
|
||||
if title.is_none() {
|
||||
return;
|
||||
}
|
||||
st.titled = true;
|
||||
}
|
||||
if let (Some(title), Some(shim)) = (title, me.shim()) {
|
||||
shim.notify(vec![Update {
|
||||
session: me.id.clone(),
|
||||
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
|
||||
@@ -1088,6 +1118,19 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
/// A title Claude Code generates from `source`, also written to the transcript.
|
||||
async fn generate_title(&self, source: String) -> Option<String> {
|
||||
let proc = self.st.lock().proc.clone()?;
|
||||
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
|
||||
match proc.control(request).await {
|
||||
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
|
||||
Err(e) => {
|
||||
tracing::debug!("generating a claude session title: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
|
||||
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
let request = msg.get("request").cloned().unwrap_or_default();
|
||||
@@ -1417,9 +1460,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
|
||||
/// The title in a session's transcript (or that of the session it continues).
|
||||
fn chain_title(session: &Session) -> Option<String> {
|
||||
history::title(&transcript_entries(session)?)
|
||||
}
|
||||
|
||||
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
|
||||
let shim = session.shim()?;
|
||||
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
|
||||
history::title(&history::read(&path).ok()?)
|
||||
history::read(&path).ok()
|
||||
}
|
||||
|
||||
fn start_json(start: &Start) -> Value {
|
||||
|
||||
@@ -311,6 +311,8 @@ struct Replay {
|
||||
sent: Arc<Mutex<Vec<Vec<Value>>>>,
|
||||
launched: Mutex<Vec<Vec<String>>>,
|
||||
errors: Arc<Mutex<Vec<String>>>,
|
||||
/// Each process's kill token.
|
||||
kills: Mutex<Vec<CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Cli for Replay {
|
||||
@@ -331,6 +333,7 @@ impl Cli for Replay {
|
||||
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
|
||||
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
|
||||
let kill = CancellationToken::new();
|
||||
self.kills.lock().push(kill.clone());
|
||||
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
|
||||
sent.lock().push(Vec::new());
|
||||
tokio::spawn(async move {
|
||||
@@ -421,14 +424,18 @@ impl Replayed {
|
||||
}
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let processes = load_fixture(name);
|
||||
let cli = Arc::new(Replay {
|
||||
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
|
||||
Arc::new(Replay {
|
||||
processes: Mutex::new(processes.into()),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
kills: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn replay(name: &str) -> Replayed {
|
||||
let cli = replay_cli(load_fixture(name));
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h =
|
||||
@@ -461,6 +468,69 @@ async fn replay_simple() {
|
||||
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
|
||||
}
|
||||
|
||||
/// The app closing the connection (an idle task released, archived or deleted) stops the
|
||||
/// CLI processes of its sessions, idle ones included.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn closing_the_connection_stops_the_processes() {
|
||||
let r = replay("simple").await;
|
||||
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
|
||||
r.h.client.kill();
|
||||
let kills = r.cli.kills.lock().clone();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for k in kills {
|
||||
k.cancelled().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("every process was stopped");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sessions_without_a_title_get_one_generated() {
|
||||
let mut processes = load_fixture("simple");
|
||||
processes[0].extend([
|
||||
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
|
||||
"request": {"subtype": "generate_session_title"}})),
|
||||
Frame::Cli(json!({"type": "control_response",
|
||||
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
|
||||
]);
|
||||
let cli = replay_cli(processes);
|
||||
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
|
||||
let h = Harness::connect(
|
||||
cli.clone(),
|
||||
config.path().into(),
|
||||
config.path().join("pending"),
|
||||
cwd_path.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
let session = h.new_session().await;
|
||||
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
|
||||
let dir = config.path().join("projects").join(history::escape(&cwd_path));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
|
||||
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
|
||||
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
|
||||
|
||||
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
|
||||
let mut titles = Vec::new();
|
||||
for _ in 0..100 {
|
||||
titles = h.updates(&session, "session_info_update");
|
||||
if !titles.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
|
||||
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
|
||||
let sent = cli.sent.lock()[0].clone();
|
||||
let request =
|
||||
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
|
||||
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
|
||||
assert_eq!(request["request"]["persist"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn replay_multi_turn() {
|
||||
let r = replay("multi_turn").await;
|
||||
@@ -727,6 +797,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
@@ -770,6 +841,7 @@ async fn a_failed_turn_reports_its_failure() {
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
kills: Mutex::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
|
||||
@@ -28,6 +28,9 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// What a thread's name is asked for with, after its first turn.
|
||||
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
|
||||
language the user writes in. Reply with the title only, without quotes or a closing period.";
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -126,6 +129,10 @@ struct Thread {
|
||||
early_cancel: Option<Instant>,
|
||||
/// The latest agent message (a subagent's answer).
|
||||
answer: String,
|
||||
/// The thread has a name.
|
||||
named: bool,
|
||||
/// A name is being asked for.
|
||||
naming: bool,
|
||||
}
|
||||
|
||||
struct PendingPrompt {
|
||||
@@ -329,6 +336,7 @@ impl CodexShim {
|
||||
t.parent = None;
|
||||
t.settings = Some(settings);
|
||||
t.cwd = cwd;
|
||||
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
|
||||
(id, options)
|
||||
}
|
||||
|
||||
@@ -625,8 +633,9 @@ impl CodexShim {
|
||||
Some(translate::plan(params))
|
||||
}),
|
||||
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
|
||||
"thread/name/updated" => self.with_thread(thread, |_, _| {
|
||||
"thread/name/updated" => self.with_thread(thread, |t, _| {
|
||||
let title = params.get("threadName").and_then(Value::as_str)?;
|
||||
t.named = true;
|
||||
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
|
||||
}),
|
||||
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
|
||||
@@ -812,9 +821,10 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_completed(&self, thread: &str, turn: &Value) {
|
||||
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
|
||||
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
let (parent, answer) = {
|
||||
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
|
||||
let (parent, answer, name) = {
|
||||
let mut state = self.state.lock();
|
||||
let Some(t) = state.threads.get_mut(thread) else { return };
|
||||
if t.turn.as_deref().is_none_or(|r| r == id) {
|
||||
@@ -830,8 +840,15 @@ impl CodexShim {
|
||||
let _ = p.done.send((turn.clone(), error.clone()));
|
||||
}
|
||||
let answer = std::mem::take(&mut t.answer);
|
||||
(t.parent.clone(), (answer, error))
|
||||
let name =
|
||||
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
|
||||
t.naming |= name;
|
||||
(t.parent.clone(), (answer, error), name)
|
||||
};
|
||||
if name {
|
||||
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
|
||||
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
|
||||
}
|
||||
let Some(parent) = parent else { return };
|
||||
let (answer, error) = answer;
|
||||
let mut state_of = match turn.get("status").and_then(Value::as_str) {
|
||||
@@ -853,6 +870,28 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
|
||||
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
|
||||
async fn name_thread(&self, thread: &str, turn: &str) {
|
||||
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
|
||||
let named = match self.call("thread/sample", sample).await {
|
||||
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
|
||||
Some(name) => {
|
||||
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
|
||||
}
|
||||
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = &named {
|
||||
tracing::debug!("naming codex thread {thread}: {}", e.message);
|
||||
}
|
||||
if let Some(t) = self.state.lock().threads.get_mut(thread) {
|
||||
t.naming = false;
|
||||
t.named |= named.is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests from codex: approvals and questions, asked as ACP permission requests.
|
||||
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
|
||||
@@ -535,8 +535,10 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
// Older recordings predate skill discovery and thread naming; play an
|
||||
// unsupported server.
|
||||
let method = actual["method"].as_str().unwrap_or_default();
|
||||
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
@@ -574,7 +576,14 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
/// Runs `scenario` against its transcript; returns the ACP results, the client and
|
||||
/// the frames the shim sent.
|
||||
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
|
||||
let t = load(scenario.file());
|
||||
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
|
||||
(results, client, sent, meta)
|
||||
}
|
||||
|
||||
/// Both connections of a replay, which stay open while it is held.
|
||||
type CodexEnd = Box<dyn std::any::Any>;
|
||||
|
||||
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 20);
|
||||
let (tr, tw) = tokio::io::split(theirs);
|
||||
let client = Arc::new(Client::default());
|
||||
@@ -590,8 +599,8 @@ async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value)
|
||||
.await
|
||||
.expect("the scenario finished");
|
||||
// Codex stays up until the scenario is over.
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
(results, client, sent, meta)
|
||||
let (sent, read, write) = player.await.unwrap();
|
||||
(results, client, sent, meta, Box::new((acp, read, write)))
|
||||
}
|
||||
|
||||
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
|
||||
@@ -801,6 +810,42 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
|
||||
assert_eq!(info["title"], "Say hi");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_unnamed_thread_is_named_after_its_first_turn() {
|
||||
let mut t = load(Scenario::Simple.file());
|
||||
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
|
||||
let thread = "01a18300-0000-7000-8000-000000000001";
|
||||
t.frames.extend([
|
||||
(true, json!({"id": 90, "method": "thread/sample"})),
|
||||
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
|
||||
(true, json!({"id": 91, "method": "thread/name/set"})),
|
||||
(false, json!({"id": 91, "result": {}})),
|
||||
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
|
||||
]);
|
||||
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
|
||||
let session = session_of(&results, "new");
|
||||
let sample = &sent(&frames, "thread/sample")[0]["params"];
|
||||
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
|
||||
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
|
||||
let mut info = Vec::new();
|
||||
for _ in 0..100 {
|
||||
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
|
||||
if !info.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_names_are_the_first_line_of_the_reply() {
|
||||
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
|
||||
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
|
||||
assert_eq!(thread_name(" \n\"\" "), None);
|
||||
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn settings_apply_to_the_next_turn() {
|
||||
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
|
||||
|
||||
@@ -612,6 +612,16 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
|
||||
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
|
||||
}
|
||||
|
||||
/// A thread name from the model's reply to the naming prompt: its first line, without
|
||||
/// quotes or a closing period. `None` when nothing is left.
|
||||
pub(super) fn thread_name(reply: &str) -> Option<String> {
|
||||
const MAX_NAME: usize = 80;
|
||||
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
|
||||
let line = line.trim_end_matches(['.', '。']).trim();
|
||||
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
|
||||
}
|
||||
|
||||
/// How a failed turn is reported.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Failure {
|
||||
|
||||
@@ -2391,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
|
||||
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id));
|
||||
mgr.rollback(&task.id, 1).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
|
||||
mgr.fork(&task.id, None).await.unwrap();
|
||||
assert!(live(&mgr, &task.id));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
|
||||
mgr.shutdown();
|
||||
|
||||
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
|
||||
|
||||
@@ -270,6 +270,8 @@ pub struct SmartKeysSettings {
|
||||
pub pair_quotes: bool,
|
||||
pub surround_selection: bool,
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
|
||||
pub simultaneous_tag_editing: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
@@ -289,6 +291,7 @@ impl Default for SmartKeysSettings {
|
||||
pair_quotes: true,
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
simultaneous_tag_editing: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
|
||||
@@ -673,6 +673,28 @@ pub struct AppearanceSettings {
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
/// Customize Main Toolbar; `None` = the default toolbar.
|
||||
pub main_toolbar: Option<MainToolbarSettings>,
|
||||
/// Appearance › Background Image: an image shown through the IDE window.
|
||||
pub background_image: BackgroundImageSettings,
|
||||
}
|
||||
|
||||
/// Appearance › Background Image. An empty `path` shows no image.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct BackgroundImageSettings {
|
||||
pub path: String,
|
||||
/// Percent, 0–100.
|
||||
pub opacity: u8,
|
||||
/// `scale` (cover the window) | `plain` (original size) | `tile`.
|
||||
pub fill: String,
|
||||
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
|
||||
/// `bottom-left`, `bottom`, `bottom-right`.
|
||||
pub anchor: String,
|
||||
}
|
||||
|
||||
impl Default for BackgroundImageSettings {
|
||||
fn default() -> Self {
|
||||
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
@@ -743,6 +765,7 @@ impl Default for AppearanceSettings {
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
background_image: BackgroundImageSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Attach to Process: the current user's processes, grouped by the debugger that can
|
||||
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
|
||||
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
|
||||
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
|
||||
//! natively (LLDB).
|
||||
|
||||
use crate::adapter;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -14,6 +15,7 @@ pub enum AttachKind {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Dotnet,
|
||||
Go,
|
||||
Native,
|
||||
}
|
||||
@@ -95,13 +97,29 @@ fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
|
||||
pub fn is_dotnet(exe: &str) -> bool {
|
||||
let path = Path::new(exe);
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
|
||||
if stem == "dotnet" {
|
||||
return true;
|
||||
}
|
||||
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
|
||||
}
|
||||
|
||||
fn is_python(name: &str) -> bool {
|
||||
let n = name.strip_suffix(".exe").unwrap_or(name);
|
||||
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
}
|
||||
|
||||
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
|
||||
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
|
||||
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
|
||||
/// interpreter claims.
|
||||
pub fn classify(
|
||||
p: &RawProcess,
|
||||
is_dotnet: impl Fn(&str) -> bool,
|
||||
is_go: impl Fn(&str) -> bool,
|
||||
native_adapter: &str,
|
||||
) -> Option<AttachableProcess> {
|
||||
let name = file_name(&p.exe).to_owned();
|
||||
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
|
||||
let (host, port) = match addr {
|
||||
@@ -129,6 +147,9 @@ pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &s
|
||||
if is_python(&name) {
|
||||
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
|
||||
}
|
||||
if is_dotnet(&p.exe) {
|
||||
return make(AttachKind::Dotnet, "netcoredbg", None);
|
||||
}
|
||||
if is_go(&p.exe) {
|
||||
return make(AttachKind::Go, "delve", None);
|
||||
}
|
||||
@@ -143,7 +164,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
|
||||
pub fn is_go_binary(exe: &str) -> bool {
|
||||
use std::io::Read;
|
||||
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
|
||||
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(exe) else { return false };
|
||||
@@ -165,6 +186,17 @@ pub fn is_go_binary(exe: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
|
||||
/// system locations skipped on Unix.
|
||||
fn is_windows_system(exe: &str) -> bool {
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
lower.get(1..).is_some_and(|rest| {
|
||||
rest.starts_with(":\\windows\\")
|
||||
|| rest.starts_with(":\\program files\\")
|
||||
|| rest.starts_with(":\\program files (x86)\\")
|
||||
})
|
||||
}
|
||||
|
||||
/// The current user's processes (without this one).
|
||||
#[cfg(unix)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
@@ -181,7 +213,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
let (pid, rest) = line.split_once(char::is_whitespace)?;
|
||||
Some((pid.parse().ok()?, rest.trim().to_owned()))
|
||||
};
|
||||
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
|
||||
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
|
||||
let exes: HashMap<u32, String> =
|
||||
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
|
||||
let me = std::process::id();
|
||||
@@ -190,9 +222,8 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.filter_map(split)
|
||||
.filter(|(pid, _)| *pid != me)
|
||||
.map(|(pid, args)| {
|
||||
let exe = exes
|
||||
.get(&pid)
|
||||
.cloned()
|
||||
let exe = exe_path(pid)
|
||||
.or_else(|| exes.get(&pid).cloned())
|
||||
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
|
||||
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
|
||||
RawProcess { pid, exe, args }
|
||||
@@ -200,9 +231,134 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
/// The processes this user may open (without this one): system and other users'
|
||||
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
|
||||
#[cfg(windows)]
|
||||
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
|
||||
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
|
||||
windows_processes::list()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_processes {
|
||||
use super::RawProcess;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
|
||||
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
|
||||
};
|
||||
|
||||
pub fn list() -> IdeResult<Vec<RawProcess>> {
|
||||
let me = std::process::id();
|
||||
Ok(pids()?
|
||||
.into_iter()
|
||||
.filter(|&pid| pid != 0 && pid != me)
|
||||
.filter_map(|pid| {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned here.
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
let exe = image_path(&process)?;
|
||||
let args = command_line(&process).unwrap_or_else(|| exe.clone());
|
||||
Some(RawProcess { pid, exe, args })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn pids() -> IdeResult<Vec<u32>> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"cannot list processes: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
let snapshot = OwnedHandle::from_raw_handle(snapshot);
|
||||
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
|
||||
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
|
||||
while more != 0 {
|
||||
out.push(entry.th32ProcessID);
|
||||
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn image_path(process: &OwnedHandle) -> Option<String> {
|
||||
let mut buf = vec![0u16; 32 * 1024];
|
||||
let mut len = buf.len() as u32;
|
||||
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
|
||||
let ok = unsafe {
|
||||
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
|
||||
};
|
||||
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
|
||||
}
|
||||
|
||||
/// The command line (Windows 8.1+), read with the same limited query access.
|
||||
fn command_line(process: &OwnedHandle) -> Option<String> {
|
||||
let mut size = 0u32;
|
||||
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
|
||||
unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if (size as usize) < size_of::<UNICODE_STRING>() {
|
||||
return None;
|
||||
}
|
||||
// u64 elements keep the UNICODE_STRING header aligned.
|
||||
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
|
||||
// SAFETY: a buffer of at least `size` bytes.
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process.as_raw_handle(),
|
||||
ProcessCommandLineInformation,
|
||||
buf.as_mut_ptr().cast(),
|
||||
size,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
if status < 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
|
||||
let text = unsafe {
|
||||
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
|
||||
if s.Buffer.is_null() {
|
||||
return None;
|
||||
}
|
||||
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
|
||||
};
|
||||
Some(String::from_utf16_lossy(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// The full executable path: `comm` keeps a relative path a process was started with.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn exe_path(pid: u32) -> Option<String> {
|
||||
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
|
||||
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
|
||||
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn exe_path(_pid: u32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Node's default inspector port, which `SIGUSR1` opens.
|
||||
@@ -244,7 +400,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
|
||||
let cwd = std::env::temp_dir();
|
||||
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
|
||||
let mut out: Vec<AttachableProcess> =
|
||||
list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect();
|
||||
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
|
||||
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
@@ -258,7 +414,8 @@ mod tests {
|
||||
}
|
||||
|
||||
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
|
||||
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
|
||||
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
|
||||
.map(|p| (p.kind, p.port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -292,11 +449,37 @@ mod tests {
|
||||
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
|
||||
Some((AttachKind::Python, None))
|
||||
);
|
||||
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
|
||||
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
|
||||
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
|
||||
assert!(!is_python("pythonista"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dotnet_hosts_and_apphosts() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let app = dir.join("Api");
|
||||
std::fs::write(&app, b"").unwrap();
|
||||
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
|
||||
let tool = dir.join("tool");
|
||||
std::fs::write(&tool, b"").unwrap();
|
||||
assert!(is_dotnet(app.to_str().unwrap()));
|
||||
assert!(!is_dotnet(tool.to_str().unwrap()));
|
||||
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
|
||||
assert!(is_dotnet("dotnet"));
|
||||
assert!(!is_dotnet("Api"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_windows_system_programs() {
|
||||
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
|
||||
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
|
||||
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
|
||||
assert!(!is_windows_system("/usr/bin/true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_go_binaries_by_buildinfo() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
|
||||
|
||||
@@ -557,6 +557,17 @@ async fn attach_to_processes() {
|
||||
extra: |pid, _| serde_json::json!({"pid": pid}),
|
||||
},
|
||||
];
|
||||
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
|
||||
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
|
||||
if dotnet_app.is_file() {
|
||||
cases.push(AttachCase {
|
||||
kind: attach::AttachKind::Dotnet,
|
||||
command: vec![s(&dotnet_app)],
|
||||
source: dir.join("dotnet/Program.cs"),
|
||||
line: 1,
|
||||
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
|
||||
});
|
||||
}
|
||||
if java_server.is_some() {
|
||||
cases.insert(
|
||||
0,
|
||||
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.7.0";
|
||||
pub const ENGINE_VERSION: &str = "1.8.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -166,6 +166,7 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
"problems-changed",
|
||||
];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
@@ -372,6 +373,11 @@ impl Manifest {
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 6)?;
|
||||
}
|
||||
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
|
||||
// that carries it.
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
|
||||
self.needs_api(&format!("`{event}`"), 8)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
|
||||
//! emitted it and whatever window it was addressed to. Plugins read this instead of
|
||||
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
|
||||
//! window lifetime, the invoking window's project, or the report loop's liveness.
|
||||
|
||||
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
|
||||
|
||||
impl DiagnosticsStore {
|
||||
/// Full replacement for one (file, producer), like the event itself.
|
||||
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
|
||||
let mut map = self.0.write();
|
||||
let sources = map.entry(path.clone()).or_default();
|
||||
if items.is_empty() {
|
||||
sources.remove(source);
|
||||
if sources.is_empty() {
|
||||
map.remove(path);
|
||||
}
|
||||
} else {
|
||||
sources.insert(source.to_owned(), items);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file's diagnostics as one entry per producer.
|
||||
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
|
||||
let map = self.0.read();
|
||||
map.iter()
|
||||
.flat_map(|(path, sources)| {
|
||||
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
|
||||
self.0.read().get(path).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drops the files under `root` (their project closed; the producers will re-report
|
||||
/// on open). Returns the number of files removed.
|
||||
pub fn forget_root(&self, root: &PathBuf) -> usize {
|
||||
let mut map = self.0.write();
|
||||
let before = map.len();
|
||||
map.retain(|path, _| !path.starts_with(root));
|
||||
before - map.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `Diagnostics` events, then delivers them as usual.
|
||||
pub struct RecordingSink {
|
||||
pub inner: SharedSink,
|
||||
pub store: Arc<DiagnosticsStore>,
|
||||
}
|
||||
|
||||
impl EventSink for RecordingSink {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
if let IdeEvent::Diagnostics { path, source, items } = &event {
|
||||
self.store.replace(&path.0.clone(), source, items.clone());
|
||||
}
|
||||
self.inner.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sink whose diagnostics are recorded; the store outlives every project.
|
||||
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
/// Wraps a sink factory so every sink it creates records diagnostics.
|
||||
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
|
||||
Box::new(move |window| {
|
||||
let inner = inner(window);
|
||||
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod agent_secrets;
|
||||
pub mod data_dirs;
|
||||
pub mod diagnostics_store;
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
@@ -58,6 +59,9 @@ pub struct HostConfig {
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
/// Every `Diagnostics` event the host ever emitted, per file and producer:
|
||||
/// the snapshot plugins read (`problems`), independent of windows and reports.
|
||||
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
@@ -135,12 +139,16 @@ impl Host {
|
||||
runtime,
|
||||
project_environment,
|
||||
} = config;
|
||||
// Every sink the host hands out records diagnostics into the shared store first.
|
||||
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
|
||||
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: RwLock::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
diagnostics,
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
@@ -180,6 +188,7 @@ impl Host {
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
ops::agent::move_user_mcp_values(&host);
|
||||
host
|
||||
}
|
||||
|
||||
@@ -287,6 +296,9 @@ impl Host {
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
// The producers will re-report when a project reopens; until then the snapshot
|
||||
// must not answer for files whose project is gone.
|
||||
self.diagnostics.forget_root(&project.root);
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
plugins.set_active_file(window, None);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! window they run on the host, next to the project they work on.
|
||||
use super::{Channel, Emit};
|
||||
use crate::Host;
|
||||
use crate::agent_secrets::{AgentSecrets, Kept};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
@@ -342,8 +343,77 @@ pub async fn accept_changes(
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
|
||||
/// servers keep their values in the project's file, where the project shares them.
|
||||
const USER_MCP_VALUES: &str = "mcp/user/";
|
||||
|
||||
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
|
||||
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
|
||||
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
|
||||
let mut values = Vec::new();
|
||||
for (server, cfg) in map.iter_mut() {
|
||||
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
|
||||
for (name, value) in entries.iter_mut() {
|
||||
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn has_mcp_values(map: &McpServerMap) -> bool {
|
||||
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
|
||||
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty()
|
||||
&& let Some(saved) = secrets.get(&key)
|
||||
{
|
||||
*value = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the values of `map` and empties them in it; values of servers and names no
|
||||
/// longer in it are forgotten.
|
||||
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
|
||||
let mut kept = Kept::Store;
|
||||
let mut current = HashSet::new();
|
||||
for (key, value) in mcp_values(map) {
|
||||
if value.is_empty() {
|
||||
secrets.delete(&key);
|
||||
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
|
||||
kept = Kept::LocalFile;
|
||||
}
|
||||
value.clear();
|
||||
current.insert(key);
|
||||
}
|
||||
for key in secrets.keys(USER_MCP_VALUES) {
|
||||
if !current.contains(&key) {
|
||||
secrets.delete(&key);
|
||||
}
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
|
||||
pub fn move_user_mcp_values(host: &Host) {
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
if !has_mcp_values(&user) {
|
||||
return;
|
||||
}
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
|
||||
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
let user = host.global_settings.effective().agents.mcp_servers;
|
||||
move_user_mcp_values(host);
|
||||
let mut user = host.global_settings.effective().agents.mcp_servers;
|
||||
fill_mcp_values(&host.agent_secrets, &mut user);
|
||||
let project = host.project(window).ok().filter(|p| !p.light);
|
||||
let workspace = project
|
||||
.as_ref()
|
||||
@@ -358,10 +428,15 @@ fn persist_mcp_map(
|
||||
scope: McpScope,
|
||||
map: &McpServerMap,
|
||||
) -> IdeResult<()> {
|
||||
let mut map = map.clone();
|
||||
let kept = match scope {
|
||||
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
|
||||
McpScope::Workspace => Kept::Store,
|
||||
};
|
||||
let value = if map.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
};
|
||||
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
|
||||
match scope {
|
||||
@@ -370,6 +445,9 @@ fn persist_mcp_map(
|
||||
for p in host.projects() {
|
||||
p.settings.reload();
|
||||
p.sink.emit(event.clone());
|
||||
if kept == Kept::LocalFile {
|
||||
host.agent_secrets.notify_kept_locally(&p.sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
@@ -418,15 +496,17 @@ fn listed(
|
||||
ide_agent::mcp::servers_from_settings(user, &workspace)
|
||||
}
|
||||
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
|
||||
/// user turned on.
|
||||
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
|
||||
/// the project's the user turned on.
|
||||
pub(crate) fn session_mcp_servers(
|
||||
settings: &ide_workspace::SettingsStore,
|
||||
approvals: &crate::trust::McpServerApprovals,
|
||||
secrets: &AgentSecrets,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<McpServer> {
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
|
||||
fill_mcp_values(secrets, &mut user);
|
||||
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
|
||||
ide_agent::mcp::servers_from_settings(&user, &workspace)
|
||||
}
|
||||
@@ -593,6 +673,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
@@ -605,6 +686,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
|
||||
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
|
||||
let secrets = AgentSecrets::new(&tmp.path().join("data"));
|
||||
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
|
||||
servers
|
||||
.into_iter()
|
||||
@@ -613,13 +695,27 @@ mod tests {
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
on(session_mcp_servers(&settings, &approvals, &root)),
|
||||
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
|
||||
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
|
||||
);
|
||||
|
||||
// A user server's saved values reach the session; the file keeps only the names.
|
||||
std::fs::write(
|
||||
config.join("settings.toml"),
|
||||
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
|
||||
settings.reload();
|
||||
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
|
||||
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
|
||||
assert_eq!(keyed.env[0].value, "saved-user");
|
||||
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
|
||||
settings.reload();
|
||||
|
||||
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
|
||||
approvals.approve(&root, "extra", &extra).unwrap();
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
|
||||
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
|
||||
McpScope::Workspace,
|
||||
"extra".to_owned(),
|
||||
"extra".to_owned()
|
||||
|
||||
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -330,21 +331,61 @@ pub async fn build_sync(
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let lsp = project.lsp.clone();
|
||||
let documents = project.documents.clone();
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
let first = updates.first().cloned();
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
let changed = {
|
||||
let lsp = lsp.clone();
|
||||
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
|
||||
};
|
||||
if let Some(file) = first
|
||||
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
|
||||
{
|
||||
tracing::debug!("openidea.java.refreshArchives: {e}");
|
||||
}
|
||||
refresh_library_classes(&lsp, &documents).await;
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
|
||||
/// again keeps its path) show the new contents, like a reopened one.
|
||||
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
|
||||
let changed = {
|
||||
let (lsp, documents) = (lsp.clone(), documents.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
|
||||
.filter(|(_, uri)| lsp.library_class_changed(uri))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (doc, uri) in changed {
|
||||
match lsp.virtual_document(&uri).await {
|
||||
Ok(vd) => {
|
||||
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
|
||||
tracing::debug!("refreshing {uri}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
@@ -1051,7 +1092,6 @@ mod maven_completion_tests {
|
||||
use ide_build::maven::completion::PomCompletionKind;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_workspace::dependencies::DependencyVersionSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cache answers the test plants before completing, so the op's Central
|
||||
/// lookups hit the session cache and the network is never reached.
|
||||
|
||||
@@ -188,8 +188,14 @@ pub async fn remote_download_into(
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
/// `password`: an FTP password typed in the settings but not applied yet.
|
||||
pub async fn deploy_test_connection(
|
||||
state: &Host,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
state.remote.test_connection_with(&connection, &path, password).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
@@ -231,6 +237,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
|
||||
});
|
||||
}
|
||||
@@ -47,14 +47,17 @@ pub async fn spell_check(
|
||||
let syntax = host.syntax();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let path = project.documents.snapshot(doc)?.path.0.clone();
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
return Ok((path, Vec::new()));
|
||||
}
|
||||
let regions = if PROSE.contains(&snap.language.as_str()) {
|
||||
ide_spell::prose_regions(&snap.text)
|
||||
} else {
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
|
||||
return Ok((path, Vec::new()));
|
||||
};
|
||||
found
|
||||
.into_iter()
|
||||
.map(|r| Region {
|
||||
@@ -72,11 +75,32 @@ pub async fn spell_check(
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
|
||||
let typos =
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
|
||||
Ok((path, typos))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
// Record the typos in the host's diagnostics snapshot so line-end annotations
|
||||
// see them without waiting for a UI report; the editor still draws from its
|
||||
// own call above, unchanged.
|
||||
if let Ok((path, typos)) = &result {
|
||||
let items: Vec<ide_core::Diagnostic> = typos
|
||||
.iter()
|
||||
.map(|t| ide_core::Diagnostic {
|
||||
range: t.range,
|
||||
severity: ide_core::Severity::Info,
|
||||
message: format!("Typo: In word '{}'", t.word),
|
||||
source: Some("Spelling".into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
})
|
||||
.collect();
|
||||
host.diagnostics.replace(path, "Spelling", items);
|
||||
}
|
||||
result.map(|(_, typos)| typos)
|
||||
}
|
||||
|
||||
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
|
||||
|
||||
@@ -3176,26 +3176,17 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(out)
|
||||
}
|
||||
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
|
||||
// The host records every `Diagnostics` event it emits (language servers,
|
||||
// builds, spelling, plugin providers), so this sees a file's problems
|
||||
// whatever window opened it — not just the ones its UI reported.
|
||||
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
|
||||
let root = host.project(client).map_err(failed)?.root.clone();
|
||||
let windows = host.windows_for_root(&root);
|
||||
host.project(client).map_err(failed)?;
|
||||
let mut out = Vec::new();
|
||||
for file in host.mcp_reports.problems(&windows) {
|
||||
if path.is_some_and(|p| p != file.path.0) {
|
||||
for (file, source, diagnostics) in host.diagnostics.problems() {
|
||||
if path.is_some_and(|p| p != file) {
|
||||
continue;
|
||||
}
|
||||
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
|
||||
for d in file.items {
|
||||
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
|
||||
}
|
||||
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
|
||||
path: to_ipc(&file.path.0),
|
||||
source,
|
||||
diagnostics,
|
||||
}));
|
||||
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -3321,6 +3312,8 @@ impl PluginWorkspace for HostWorkspace {
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
// Any open project may publish problems for any file: annotations follow the
|
||||
// file, not the window (an editor can show a file outside every project root).
|
||||
let projects = HostWorkspace::projects(self);
|
||||
let project = projects
|
||||
.iter()
|
||||
@@ -3332,7 +3325,8 @@ impl PluginWorkspace for HostWorkspace {
|
||||
.iter()
|
||||
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
|
||||
})
|
||||
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
|
||||
.or_else(|| projects.first())
|
||||
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
|
||||
project.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: format!("plugin:{source}"),
|
||||
@@ -4135,16 +4129,23 @@ impl PluginApi {
|
||||
return Err(HostError::Invalid("reversed decoration range".into()));
|
||||
}
|
||||
let replacement = if let Some(path) = path {
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
|
||||
let (root, real) = workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
|
||||
.max_by_key(|(root, _)| root.components().count())
|
||||
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
|
||||
// The decoration follows the file, not the window's project: editors render
|
||||
// whatever they have open, including files outside every project root. The
|
||||
// set's root stays the path's own (deepest open) project for tree filtering.
|
||||
let root = self
|
||||
.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
|
||||
Some(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: real.into(),
|
||||
path: ide_core::IpcPath(path.to_path_buf()),
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
@@ -4188,6 +4189,8 @@ fn decoration_style(style: &str) -> bool {
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "typo"
|
||||
| "weak"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "muted"
|
||||
@@ -4308,8 +4311,11 @@ mod workbench_tests {
|
||||
};
|
||||
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
|
||||
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
|
||||
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
|
||||
// Decorations follow the file: a path outside the open project is accepted too.
|
||||
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
|
||||
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
|
||||
api.decorations(None, "set", vec![]).unwrap();
|
||||
assert!(api.shared.decoration_sets()["test"].is_empty());
|
||||
for i in 0..50 {
|
||||
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
|
||||
}
|
||||
@@ -4350,6 +4356,29 @@ mod workbench_tests {
|
||||
api.shared.clear_plugin("test", true);
|
||||
assert!(api.shared.decoration_sets().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
|
||||
// Inspection Lens colours typos and weak warnings with their own styles; the
|
||||
// host must keep them, not silently drop them the way an unknown style is dropped.
|
||||
for style in [
|
||||
"highlight",
|
||||
"search",
|
||||
"added",
|
||||
"modified",
|
||||
"removed",
|
||||
"warning",
|
||||
"error",
|
||||
"info",
|
||||
"typo",
|
||||
"weak",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"muted",
|
||||
] {
|
||||
assert!(decoration_style(style), "{style} should be a known style");
|
||||
}
|
||||
assert!(!decoration_style("unknown"));
|
||||
}
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
@@ -129,6 +129,7 @@ impl PluginHost {
|
||||
}
|
||||
let path = match event {
|
||||
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
|
||||
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
|
||||
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
|
||||
_ => return Some(event.clone()),
|
||||
};
|
||||
@@ -136,16 +137,25 @@ impl PluginHost {
|
||||
return None;
|
||||
}
|
||||
let workspace = workspace.as_ref()?;
|
||||
let real = ide_plugin::resolve_real(path);
|
||||
// The file's own project, not the invoking window's: annotations follow
|
||||
// the file, so a plugin hears about a file another window has open.
|
||||
let root = match client {
|
||||
Some(client) => workspace.project_of(client),
|
||||
None => {
|
||||
let real = ide_plugin::resolve_real(path);
|
||||
workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}
|
||||
Some(client) => workspace
|
||||
.project_of(client)
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.or_else(|| {
|
||||
workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}),
|
||||
None => workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count()),
|
||||
}?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return None;
|
||||
|
||||
@@ -352,6 +352,15 @@ where
|
||||
deserializer.deserialize_map(Properties)
|
||||
}
|
||||
|
||||
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
|
||||
/// message an Inspection Lens severity produces.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct SettingExample {
|
||||
pub text: String,
|
||||
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
|
||||
pub style: String,
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -371,6 +380,8 @@ pub struct SettingSchema {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub example: Option<SettingExample>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<SettingSchema>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub properties: Vec<SettingSchema>,
|
||||
@@ -455,6 +466,20 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
|
||||
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
|
||||
}
|
||||
};
|
||||
let example = match o.get("example") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let e: SettingExample = serde_json::from_value(v.clone())
|
||||
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
|
||||
if e.text.is_empty() || e.text.len() > 300 {
|
||||
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
|
||||
}
|
||||
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
let s = SettingSchema {
|
||||
key: key.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
@@ -464,6 +489,7 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
|
||||
options,
|
||||
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
|
||||
group: text("group", MAX_TITLE)?,
|
||||
example,
|
||||
items,
|
||||
properties,
|
||||
};
|
||||
|
||||
@@ -675,9 +675,10 @@ impl Host {
|
||||
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
|
||||
}
|
||||
let approvals = self.mcp_approvals.clone();
|
||||
let secrets = self.agent_secrets.clone();
|
||||
let root = root.to_path_buf();
|
||||
agents.set_mcp_servers_hook(Box::new(move || {
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
|
||||
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
|
||||
}));
|
||||
(db, http_history, agents)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Values of the user's MCP servers' environment variables and HTTP headers live in the
|
||||
//! credential store; `settings.toml` keeps only their names. A project's own servers keep
|
||||
//! their values in the project's file, where the project shares them.
|
||||
|
||||
use ide_agent::{McpKeyValue, McpScope, McpServer, McpServerInput};
|
||||
use ide_core::NullSink;
|
||||
use ide_core::settings::McpTransport;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const VALUE: &str = "mcp-value-0f3c9a";
|
||||
|
||||
fn host(dir: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: dir.join("config"),
|
||||
data_dir: dir.join("data"),
|
||||
local_data_dir: dir.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn project(tmp: &tempfile::TempDir) -> PathBuf {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn open(host: &Host, root: &Path) {
|
||||
host.trust.decide(root, true, false).unwrap();
|
||||
host.open_project("main", root).unwrap();
|
||||
}
|
||||
|
||||
fn header(servers: &[McpServer], id: &str) -> String {
|
||||
let server = servers.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} in {servers:?}"));
|
||||
server.headers.iter().find(|h| h.name == "Authorization").map(|h| h.value.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn input(name: &str, scope: McpScope) -> McpServerInput {
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: name.into(),
|
||||
scope,
|
||||
transport: McpTransport::Http,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: vec![],
|
||||
env: vec![],
|
||||
url: Some("https://mcp.example/v1".into()),
|
||||
headers: vec![McpKeyValue { name: "Authorization".into(), value: format!("Bearer {VALUE}") }],
|
||||
agents: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_WITH_VALUE: &str = r#"
|
||||
[agents.mcpServers.remote]
|
||||
transport = "http"
|
||||
enabled = true
|
||||
url = "https://mcp.example/v1"
|
||||
headers = { Authorization = "Bearer mcp-value-0f3c9a" }
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_server_values_stay_out_of_the_user_settings() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
for (name, scope) in [("user-server", McpScope::User), ("repo-server", McpScope::Workspace)] {
|
||||
ide_host::ops::agent::mcp_save(&host, "main", input(name, scope)).unwrap();
|
||||
}
|
||||
host.close_project("main");
|
||||
|
||||
// Only the project's own file holds a value; nothing in the user's config or data does.
|
||||
let found = files_containing(tmp.path(), VALUE);
|
||||
assert!(found.len() == 1 && found[0].ends_with(Path::new(".openidea/settings.toml")), "{found:?}");
|
||||
|
||||
let host = self::host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::user-server"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(header(&servers, "workspace::repo-server"), format!("Bearer {VALUE}"));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn values_in_user_settings_move_to_the_credential_store() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
|
||||
let saved = SERVER_WITH_VALUE.replace("Authorization =", "X-Saved = \"\", Authorization =");
|
||||
std::fs::write(tmp.path().join("config/settings.toml"), saved).unwrap();
|
||||
ide_host::agent_secrets::AgentSecrets::new(&tmp.path().join("data"))
|
||||
.set("mcp/user/remote/header/X-Saved", "saved-earlier")
|
||||
.unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "user::remote"), format!("Bearer {VALUE}"));
|
||||
let remote = servers.iter().find(|s| s.id == "user::remote").unwrap();
|
||||
assert!(remote.headers.iter().any(|h| h.name == "X-Saved" && h.value == "saved-earlier"), "{remote:?}");
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_projects_own_values_stay_in_its_file() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = project(&tmp);
|
||||
let file = root.join(".openidea/settings.toml");
|
||||
std::fs::write(&file, SERVER_WITH_VALUE).unwrap();
|
||||
let host = host(tmp.path());
|
||||
open(&host, &root);
|
||||
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), SERVER_WITH_VALUE, "reading leaves the project file alone");
|
||||
|
||||
// Saving the servers keeps the project's values in the project's file for everyone else.
|
||||
let servers = ide_host::ops::agent::mcp_set_enabled(&host, "main", "workspace::remote".into(), false).unwrap();
|
||||
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains(VALUE));
|
||||
assert_eq!(files_containing(&tmp.path().join("data"), VALUE), Vec::<PathBuf>::new());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -430,3 +430,61 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
|
||||
host.shutdown().await;
|
||||
set_editor_providers(None);
|
||||
}
|
||||
|
||||
/// A host with a project open: `problems` and `decorations` need a workspace.
|
||||
fn harness(root: &Path) -> Arc<Host> {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: root.join("config"),
|
||||
data_dir: root.join("data"),
|
||||
local_data_dir: root.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", root).unwrap();
|
||||
host
|
||||
}
|
||||
|
||||
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
|
||||
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
|
||||
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
plugins
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = harness(tmp.path());
|
||||
let plugins = plugins_on(&host, tmp.path());
|
||||
let path = tmp.path().join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
|
||||
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
|
||||
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
|
||||
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
|
||||
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
|
||||
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = harness(tmp.path());
|
||||
let plugins = plugins_on(&host, tmp.path());
|
||||
let path = tmp.path().join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
// `decorate` publishes both styles; the host drops an unknown style silently, so a
|
||||
// rejection here means one of the two is not a known decoration style.
|
||||
plugins
|
||||
.run_command(
|
||||
ID,
|
||||
"decorate",
|
||||
Some(path.to_str().unwrap()),
|
||||
Invocation { client: Some("main".into()), ..Invocation::default() },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -139,7 +139,7 @@ async fn text_search_uses_overlay_excludes_utf16_and_limits() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
@@ -153,21 +153,30 @@ async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
deprecated: false,
|
||||
server: None,
|
||||
};
|
||||
f.host.mcp_reports.report_problems(
|
||||
"second",
|
||||
true,
|
||||
vec![ide_host::mcp::FileProblems {
|
||||
// Producers publish per (file, producer) through the event sink, like the
|
||||
// language servers and the spelling check do.
|
||||
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
|
||||
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.clone().into(),
|
||||
items: vec![diagnostic("lsp"), diagnostic("plugin")],
|
||||
}],
|
||||
);
|
||||
source: source.into(),
|
||||
items,
|
||||
});
|
||||
};
|
||||
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
|
||||
publish(&f.host, "build", vec![diagnostic("plugin")]);
|
||||
// A file outside the window's project is visible too: annotations follow the file.
|
||||
let outside = f.host.diagnostics.problems().len();
|
||||
let workspace = f.workspace();
|
||||
let files = workspace.problems("main", Some(&path)).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
|
||||
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
|
||||
f.host.mcp_reports.forget_window("second");
|
||||
assert!(workspace.problems("main", None).unwrap().is_empty());
|
||||
assert_eq!(outside, 2);
|
||||
// A full replacement per producer clears only that producer.
|
||||
publish(&f.host, "rust-analyzer", vec![]);
|
||||
let files = workspace.problems("main", None).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].source, "build");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
f.install(&official("git-toolbox"));
|
||||
// Focusing an editor wakes it to show the branch (nothing fetched yet).
|
||||
let now = Instant::now();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2609,6 +2610,82 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
assert!(err.contains("Restricted Mode"), "{err}");
|
||||
}
|
||||
|
||||
/// Git Toolbox in a workspace window whose container folder is not a
|
||||
/// repository (every member is its own clone): the status bar follows the
|
||||
/// focused file's repository, and the scheduled fetch covers every member.
|
||||
#[test]
|
||||
fn official_git_toolbox_follows_members_in_a_workspace() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A remote with `main`, cloned by member `a` (at `main`) and member `b`
|
||||
// (on its own branch, to tell the two apart in the status bar).
|
||||
let remote = tmp.path().join("remote.git");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
git(&remote, &["init", "--bare", "-b", "main"]);
|
||||
let seed = tmp.path().join("seed");
|
||||
std::fs::create_dir(&seed).unwrap();
|
||||
git(&seed, &["init", "-b", "main"]);
|
||||
std::fs::write(seed.join("seed.txt"), "seed\n").unwrap();
|
||||
git(&seed, &["add", "."]);
|
||||
git(&seed, &["commit", "-m", "one"]);
|
||||
git(&seed, &["remote", "add", "origin", remote.to_str().unwrap()]);
|
||||
git(&seed, &["push", "-u", "origin", "main"]);
|
||||
let member_a = tmp.path().join("member-a");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_a.to_str().unwrap()]);
|
||||
let member_a = dunce::canonicalize(member_a).unwrap();
|
||||
let member_b = tmp.path().join("member-b");
|
||||
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_b.to_str().unwrap()]);
|
||||
let member_b = dunce::canonicalize(member_b).unwrap();
|
||||
git(&member_b, &["checkout", "-b", "feature"]);
|
||||
git(&member_b, &["push", "-u", "origin", "feature"]);
|
||||
// The window's project is the container; the members are attached to it.
|
||||
f.workspace.other_roots.lock().extend([member_a.clone(), member_b.clone()]);
|
||||
|
||||
f.install(&official("git-toolbox"));
|
||||
let item = |host: &PluginHost| {
|
||||
host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch")
|
||||
};
|
||||
let now = Instant::now();
|
||||
let focus = |path: &Path| {
|
||||
f.host
|
||||
.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(path.to_string_lossy().into_owned()))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
f.host.wait_events(Duration::from_secs(10));
|
||||
};
|
||||
// Focusing a file of member `a` shows that member's branch: the window's
|
||||
// project is the container, which is no repository.
|
||||
focus(&member_a.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into()));
|
||||
// Fetch Now fetches the shown member; the container is not a repository.
|
||||
f.run("openidea.git-toolbox", "fetch", None).unwrap();
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(&member_a))),
|
||||
"{member_a:?}: {fetched:?}"
|
||||
);
|
||||
assert_eq!(fetched.iter().filter(|(key, _)| key.starts_with("last-fetch:")).count(), 1, "{fetched:?}");
|
||||
// Switching to the other member follows it.
|
||||
focus(&member_b.join("seed.txt"));
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
|
||||
// Idle: reclaimed; the schedule then wakes it to fetch every member (the
|
||||
// container is skipped: it has no repository).
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
f.host.tick();
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()), "the item stays while it sleeps");
|
||||
f.take_events();
|
||||
f.host.tick_at(Instant::now() + Duration::from_secs(301));
|
||||
assert!(f.host.wait_events(Duration::from_secs(30)));
|
||||
let fetched = stored(&f, "openidea.git-toolbox");
|
||||
for member in [&member_a, &member_b] {
|
||||
assert!(
|
||||
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(member))),
|
||||
"{member:?}: {fetched:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
|
||||
}
|
||||
|
||||
/// A tiny HTTP server: each request is answered after `delay(n)` (n counts
|
||||
/// requests) with `body`. Returns its port and the request bodies it got.
|
||||
fn http_server(
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
|
||||
host.remote.test_connection(&connection, "/projects").await.unwrap();
|
||||
let local = tmp.path().join("local-deployment");
|
||||
std::fs::create_dir(&local).unwrap();
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
//! In-memory index: one slot per file, symbols stored with their file.
|
||||
//! In-memory index: one slot per file, with compact query-time tables derived from it.
|
||||
//!
|
||||
//! Queries never walk the fat `FileEntry` structs: `HotFiles`, `HotDirs` and `SymTable` hold
|
||||
//! only the fields a scan needs, packed in dense rows with interned strings, so a query
|
||||
//! streams contiguous memory instead of chasing `Box<[Sym]>` / `Box<str>` pointers.
|
||||
//! They are plain data, updated eagerly by `insert`/`remove` (every mutation needs `&mut
|
||||
//! IndexData`), and all queries run under the `IndexData` read lock: a scan therefore sees
|
||||
//! one consistent snapshot, and the only "invalidation" is the update itself. Space left
|
||||
//! behind by replaced/removed entries is reclaimed by compaction inside `insert`/`remove`
|
||||
//! once it dominates the table, and by `shrink`.
|
||||
|
||||
use ide_core::{Range, SymbolKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -33,6 +42,40 @@ pub(crate) fn mask_bit(b: u8) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A substring of an interned text arena.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Span {
|
||||
pub start: u32,
|
||||
pub len: u32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// A slice of nothing; used for optional fields (`None` containers).
|
||||
pub(crate) const NONE: Span = Span { start: u32::MAX, len: 0 };
|
||||
|
||||
/// Appends `s` to `arena`, returning the span it will occupy.
|
||||
///
|
||||
/// Spans are `u32`-addressed and `NONE` uses `u32::MAX` as its start, so an
|
||||
/// arena that reaches that bound cannot be represented. Fail loudly here
|
||||
/// instead of truncating the offsets: a wrapped span would make [`Self::get`]
|
||||
/// slice unrelated text, or panic far from the actual cause.
|
||||
#[inline]
|
||||
fn of(arena: &str, s: &str) -> Self {
|
||||
let start = u32::try_from(arena.len()).expect("index text arena exceeded the 4 GiB span limit");
|
||||
let len = u32::try_from(s.len()).expect("index text span exceeded the 4 GiB span limit");
|
||||
assert!(
|
||||
start != u32::MAX && start.checked_add(len).is_some(),
|
||||
"index text arena exceeded the 4 GiB span limit"
|
||||
);
|
||||
Self { start, len }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn get(self, arena: &str) -> &str {
|
||||
&arena[self.start as usize..(self.start + self.len) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted per-file record (postcard in redb, keyed by the relative path).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct FileRecord {
|
||||
@@ -52,12 +95,12 @@ pub(crate) struct StoredSymbol {
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
pub(crate) struct Sym {
|
||||
pub name: Box<str>,
|
||||
/// A symbol resolved out of [`SymTable`].
|
||||
pub(crate) struct SymRef<'a> {
|
||||
pub name: &'a str,
|
||||
pub kind: SymbolKind,
|
||||
pub container: Option<Box<str>>,
|
||||
pub container: Option<&'a str>,
|
||||
pub range: Range,
|
||||
pub mask: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct FileEntry {
|
||||
@@ -73,38 +116,34 @@ pub(crate) struct FileEntry {
|
||||
pub mtime: u64,
|
||||
pub size: u64,
|
||||
pub hash: [u8; 32],
|
||||
pub symbols: Box<[Sym]>,
|
||||
/// Range of this file's symbols in [`IndexData::syms`].
|
||||
pub sym_start: u32,
|
||||
pub sym_len: u32,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self {
|
||||
/// Splits the record into the entry and its symbols; the symbols are flattened into
|
||||
/// the index-wide [`SymTable`] by [`IndexData::insert`].
|
||||
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> (Self, Vec<StoredSymbol>) {
|
||||
let name_start = rel.rfind('/').map_or(0, |i| i + 1);
|
||||
let name = &rel[name_start..];
|
||||
let symbols = record
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|s| Sym {
|
||||
mask: char_mask(&s.name),
|
||||
name: s.name.into_boxed_str(),
|
||||
kind: s.kind,
|
||||
container: s.container.map(String::into_boxed_str),
|
||||
range: s.range,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
let FileRecord { mtime, size, hash, symbols, ignored } = record;
|
||||
let entry = Self {
|
||||
name_start_chars: rel[..name_start].chars().count() as u32,
|
||||
name_mask: char_mask(name),
|
||||
rel_mask: char_mask(&rel),
|
||||
name_start: name_start as u32,
|
||||
path,
|
||||
rel: rel.into_boxed_str(),
|
||||
mtime: record.mtime,
|
||||
size: record.size,
|
||||
hash: record.hash,
|
||||
symbols,
|
||||
ignored: record.ignored,
|
||||
}
|
||||
mtime,
|
||||
size,
|
||||
hash,
|
||||
sym_start: 0,
|
||||
sym_len: 0,
|
||||
ignored,
|
||||
};
|
||||
(entry, symbols)
|
||||
}
|
||||
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
@@ -115,38 +154,391 @@ impl FileEntry {
|
||||
&self.rel[..self.name_start as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn record(&self) -> FileRecord {
|
||||
FileRecord {
|
||||
mtime: self.mtime,
|
||||
size: self.size,
|
||||
hash: self.hash,
|
||||
symbols: self
|
||||
.symbols
|
||||
.iter()
|
||||
.map(|s| StoredSymbol {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
ignored: self.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the entry is out of date for `found` on disk.
|
||||
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
|
||||
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
|
||||
}
|
||||
}
|
||||
|
||||
/// The hot fields of one file slot, packed for the query scan; no pointers.
|
||||
pub(crate) struct FileRow {
|
||||
/// `rel` in [`HotFiles`]' arena.
|
||||
pub rel: Span,
|
||||
/// Byte offset of the file name inside `rel`.
|
||||
pub name_start: u32,
|
||||
pub sym_start: u32,
|
||||
pub sym_len: u32,
|
||||
/// Union of every symbol's [`char_mask`]: a selective symbol query can skip
|
||||
/// the whole file before touching its symbol range.
|
||||
pub sym_mask: u64,
|
||||
pub rel_mask: u64,
|
||||
pub name_mask: u64,
|
||||
/// Mask of the directory part (`rel[..name_start]`), precomputed for path queries.
|
||||
pub dir_mask: u64,
|
||||
/// `rel` is pure ASCII, so nucleo can match it without a UTF-32 conversion.
|
||||
pub ascii: bool,
|
||||
/// The slot holds a file; free slots keep a row so scans line up with `IndexData::files`.
|
||||
pub live: bool,
|
||||
}
|
||||
|
||||
impl FileRow {
|
||||
#[inline]
|
||||
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
|
||||
self.rel.get(arena)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn name<'a>(&self, arena: &'a str) -> &'a str {
|
||||
&self.rel.get(arena)[self.name_start as usize..]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn dir<'a>(&self, arena: &'a str) -> &'a str {
|
||||
&self.rel.get(arena)[..self.name_start as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// One dense row per file slot (same order and length as `IndexData::files`) plus the
|
||||
/// text arena the rows point into.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HotFiles {
|
||||
pub rows: Vec<FileRow>,
|
||||
text: String,
|
||||
dead_text: usize,
|
||||
}
|
||||
|
||||
impl HotFiles {
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn row(&self, slot: usize) -> Option<&FileRow> {
|
||||
self.rows.get(slot).filter(|row| row.live)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn row_of_slot(&self, slot: u32) -> Option<&FileRow> {
|
||||
self.row(slot as usize)
|
||||
}
|
||||
|
||||
/// `rel` of a live slot; candidate slots always are.
|
||||
#[inline]
|
||||
pub(crate) fn rel(&self, slot: u32) -> &str {
|
||||
self.rows[slot as usize].rel(&self.text)
|
||||
}
|
||||
|
||||
fn set(&mut self, slot: u32, row: FileRow) {
|
||||
if self.rows.len() as u32 == slot {
|
||||
self.rows.push(row);
|
||||
} else {
|
||||
self.rows[slot as usize] = row;
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, rel: &str) -> Span {
|
||||
let span = Span::of(&self.text, rel);
|
||||
self.text.push_str(rel);
|
||||
span
|
||||
}
|
||||
|
||||
/// Updates the row of an existing slot (replace) in place.
|
||||
fn update(&mut self, slot: u32, e: &FileEntry, sym_mask: u64) {
|
||||
let idx = slot as usize;
|
||||
if self.rows[idx].rel.get(&self.text) != &*e.rel {
|
||||
self.dead_text += self.rows[idx].rel.len as usize;
|
||||
let span = self.append(&e.rel);
|
||||
self.rows[idx].rel = span;
|
||||
self.rows[idx].ascii = e.rel.is_ascii();
|
||||
self.rows[idx].dir_mask = char_mask(e.dir());
|
||||
}
|
||||
let row = &mut self.rows[idx];
|
||||
row.name_start = e.name_start;
|
||||
row.name_mask = e.name_mask;
|
||||
row.rel_mask = e.rel_mask;
|
||||
row.sym_start = e.sym_start;
|
||||
row.sym_len = e.sym_len;
|
||||
row.sym_mask = sym_mask;
|
||||
row.live = true;
|
||||
}
|
||||
|
||||
fn remove(&mut self, slot: u32) {
|
||||
let row = &mut self.rows[slot as usize];
|
||||
self.dead_text += row.rel.len as usize;
|
||||
row.rel = Span::NONE;
|
||||
row.sym_start = 0;
|
||||
row.sym_len = 0;
|
||||
row.sym_mask = 0;
|
||||
row.live = false;
|
||||
}
|
||||
|
||||
fn compact(&mut self) {
|
||||
let mut text = String::with_capacity(self.text.len() - self.dead_text);
|
||||
for row in &mut self.rows {
|
||||
if !row.live {
|
||||
continue;
|
||||
}
|
||||
let rel = row.rel.get(&self.text);
|
||||
row.rel = Span::of(&text, rel);
|
||||
text.push_str(rel);
|
||||
}
|
||||
self.text = text;
|
||||
self.dead_text = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.compact();
|
||||
self.rows.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
/// One folder holding indexed files, packed for the query scan.
|
||||
pub(crate) struct DirRow {
|
||||
/// `rel` in [`HotDirs`]' arena, including the trailing `/`.
|
||||
pub rel: Span,
|
||||
/// Byte offset of the last path segment inside the trimmed `rel`.
|
||||
pub name_start: u32,
|
||||
pub name_mask: u64,
|
||||
/// Mask of `rel` without the trailing `/`.
|
||||
pub rel_mask: u64,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl DirRow {
|
||||
#[inline]
|
||||
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
|
||||
self.rel.get(arena)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HotDirs {
|
||||
pub rows: Vec<DirRow>,
|
||||
text: String,
|
||||
dead: usize,
|
||||
}
|
||||
|
||||
impl HotDirs {
|
||||
#[inline]
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
fn push(&mut self, rel: &str, abs: &Path) -> u32 {
|
||||
let span = Span::of(&self.text, rel);
|
||||
self.text.push_str(rel);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name_start = trimmed.rfind('/').map_or(0, |i| i + 1);
|
||||
self.rows.push(DirRow {
|
||||
rel: span,
|
||||
name_start: name_start as u32,
|
||||
name_mask: char_mask(&trimmed[name_start..]),
|
||||
rel_mask: char_mask(trimmed),
|
||||
path: abs.to_owned(),
|
||||
});
|
||||
(self.rows.len() - 1) as u32
|
||||
}
|
||||
|
||||
fn remove(&mut self, row: u32, dirs: &mut HashMap<Box<str>, (PathBuf, u32, u32)>) {
|
||||
let idx = row as usize;
|
||||
let last = self.rows.len() - 1;
|
||||
self.dead += self.rows[idx].rel.len as usize;
|
||||
self.rows.swap_remove(idx);
|
||||
if idx != last {
|
||||
let moved = self.rows[idx].rel.get(&self.text);
|
||||
if let Some(dir) = dirs.get_mut(moved) {
|
||||
dir.2 = idx as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact(&mut self) {
|
||||
let mut text = String::with_capacity(self.text.len() - self.dead);
|
||||
for row in &mut self.rows {
|
||||
let rel = row.rel.get(&self.text);
|
||||
row.rel = Span::of(&text, rel);
|
||||
text.push_str(rel);
|
||||
}
|
||||
self.text = text;
|
||||
self.dead = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.compact();
|
||||
self.rows.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
/// The scan-relevant fields of one symbol: its character mask and the name in the
|
||||
/// arena. Interleaved so a scan touches one cache line per candidate instead of three
|
||||
/// parallel arrays.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SymHead {
|
||||
pub mask: u64,
|
||||
pub name: Span,
|
||||
}
|
||||
|
||||
/// Index-wide symbol storage: one row per symbol in parallel arrays, with names and
|
||||
/// containers interned in a single arena. Each file points at a contiguous range.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SymTable {
|
||||
head: Vec<SymHead>,
|
||||
container: Vec<Span>,
|
||||
kind: Vec<SymbolKind>,
|
||||
range: Vec<Range>,
|
||||
text: String,
|
||||
dead_rows: usize,
|
||||
}
|
||||
|
||||
impl SymTable {
|
||||
#[inline]
|
||||
pub(crate) fn name(&self, i: u32) -> &str {
|
||||
self.head[i as usize].name.get(&self.text)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn mask(&self, i: u32) -> u64 {
|
||||
self.head[i as usize].mask
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn container(&self, i: u32) -> Option<&str> {
|
||||
let span = self.container[i as usize];
|
||||
(span.start != u32::MAX).then(|| span.get(&self.text))
|
||||
}
|
||||
|
||||
/// The mask + name rows of a file's contiguous symbol range.
|
||||
#[inline]
|
||||
pub(crate) fn heads(&self, start: u32, len: u32) -> &[SymHead] {
|
||||
let (start, end) = (start as usize, start as usize + len as usize);
|
||||
&self.head[start..end]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn kind(&self, i: u32) -> SymbolKind {
|
||||
self.kind[i as usize]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn range(&self, i: u32) -> Range {
|
||||
self.range[i as usize]
|
||||
}
|
||||
|
||||
fn intern(&mut self, s: &str) -> Span {
|
||||
let span = Span::of(&self.text, s);
|
||||
self.text.push_str(s);
|
||||
span
|
||||
}
|
||||
|
||||
/// Appends a file's symbols; they stay contiguous. Returns the start index
|
||||
/// and the union of their masks.
|
||||
fn push(&mut self, symbols: &[StoredSymbol]) -> (u32, u64) {
|
||||
let start = self.head.len() as u32;
|
||||
let mut agg_mask = 0u64;
|
||||
for s in symbols {
|
||||
let mask = char_mask(&s.name);
|
||||
agg_mask |= mask;
|
||||
let name = self.intern(&s.name);
|
||||
let container = match &s.container {
|
||||
Some(c) => self.intern(c),
|
||||
None => Span::NONE,
|
||||
};
|
||||
self.head.push(SymHead { mask, name });
|
||||
self.container.push(container);
|
||||
self.kind.push(s.kind);
|
||||
self.range.push(s.range);
|
||||
}
|
||||
(start, agg_mask)
|
||||
}
|
||||
|
||||
/// Whether `symbols` would flatten to exactly the range at `start`.
|
||||
fn same(&self, start: u32, len: u32, symbols: &[StoredSymbol]) -> bool {
|
||||
len as usize == symbols.len()
|
||||
&& symbols.iter().enumerate().all(|(k, s)| {
|
||||
let i = start + k as u32;
|
||||
self.name(i) == s.name
|
||||
&& self.container(i) == s.container.as_deref()
|
||||
&& self.kind(i) == s.kind
|
||||
&& self.range(i) == s.range
|
||||
})
|
||||
}
|
||||
|
||||
fn free(&mut self, _start: u32, len: u32) {
|
||||
self.dead_rows += len as usize;
|
||||
}
|
||||
|
||||
/// Rewrites the live rows in file order, reclaiming the space of replaced files.
|
||||
/// `files` entries keep their range in sync; queries read rows, but persistence
|
||||
/// and `FileEntry` accessors go through the entry.
|
||||
fn compact(&mut self, rows: &mut [FileRow], files: &mut [Option<FileEntry>]) {
|
||||
let mut text = String::with_capacity(self.text.len());
|
||||
let mut head = Vec::with_capacity(self.head.len() - self.dead_rows);
|
||||
let mut container = Vec::with_capacity(head.capacity());
|
||||
let mut kind = Vec::with_capacity(head.capacity());
|
||||
let mut range = Vec::with_capacity(head.capacity());
|
||||
let mut live = 0u32;
|
||||
for (slot, row) in rows.iter_mut().enumerate() {
|
||||
if !row.live {
|
||||
row.sym_start = 0;
|
||||
continue;
|
||||
}
|
||||
let (start, end) = (row.sym_start, row.sym_start + row.sym_len);
|
||||
row.sym_start = live;
|
||||
if let Some(e) = files.get_mut(slot).and_then(Option::as_mut) {
|
||||
e.sym_start = live;
|
||||
}
|
||||
for i in start..end {
|
||||
let name_span = self.head[i as usize].name;
|
||||
let name = name_span.get(&self.text);
|
||||
head.push(SymHead { mask: self.head[i as usize].mask, name: Span::of(&text, name) });
|
||||
text.push_str(name);
|
||||
let container_span = self.container[i as usize];
|
||||
if container_span.start == u32::MAX {
|
||||
container.push(Span::NONE);
|
||||
} else {
|
||||
let c = container_span.get(&self.text);
|
||||
container.push(Span::of(&text, c));
|
||||
text.push_str(c);
|
||||
}
|
||||
kind.push(self.kind[i as usize]);
|
||||
range.push(self.range[i as usize]);
|
||||
live += 1;
|
||||
}
|
||||
}
|
||||
self.head = head;
|
||||
self.container = container;
|
||||
self.kind = kind;
|
||||
self.range = range;
|
||||
self.text = text;
|
||||
self.dead_rows = 0;
|
||||
}
|
||||
|
||||
fn shrink(&mut self) {
|
||||
self.head.shrink_to_fit();
|
||||
self.container.shrink_to_fit();
|
||||
self.kind.shrink_to_fit();
|
||||
self.range.shrink_to_fit();
|
||||
self.text.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IndexData {
|
||||
pub files: Vec<Option<FileEntry>>,
|
||||
by_path: HashMap<PathBuf, u32>,
|
||||
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
|
||||
/// trailing `/`: its absolute path and the number of files below it.
|
||||
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
|
||||
/// trailing `/`: its absolute path, the number of files below it, and its row in
|
||||
/// [`HotDirs`].
|
||||
pub dirs: HashMap<Box<str>, (PathBuf, u32, u32)>,
|
||||
/// Compact query-time view of `files`, kept in sync by `insert`/`remove`.
|
||||
pub hot: HotFiles,
|
||||
/// Compact query-time view of `dirs`, kept in sync by `insert`/`remove`.
|
||||
pub dirs_hot: HotDirs,
|
||||
/// Flattened symbols of every file, kept in sync by `insert`/`remove`.
|
||||
pub syms: SymTable,
|
||||
free: Vec<u32>,
|
||||
pub file_count: u32,
|
||||
pub symbol_count: u32,
|
||||
@@ -161,49 +553,124 @@ impl IndexData {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, entry: FileEntry) {
|
||||
let symbols = entry.symbols.len() as u32;
|
||||
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
|
||||
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
|
||||
FileRecord {
|
||||
mtime: e.mtime,
|
||||
size: e.size,
|
||||
hash: e.hash,
|
||||
symbols: self
|
||||
.symbols_of(e)
|
||||
.map(|s| StoredSymbol {
|
||||
name: s.name.to_owned(),
|
||||
kind: s.kind,
|
||||
container: s.container.map(str::to_owned),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
ignored: e.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_of(&self, path: &Path) -> Option<FileRecord> {
|
||||
self.get(path).map(|e| self.record(e))
|
||||
}
|
||||
|
||||
/// The symbols of one file, in declaration order.
|
||||
pub(crate) fn symbols_of<'a>(&'a self, e: &'a FileEntry) -> impl Iterator<Item = SymRef<'a>> + 'a {
|
||||
let syms = &self.syms;
|
||||
(e.sym_start..e.sym_start + e.sym_len).map(move |i| SymRef {
|
||||
name: syms.name(i),
|
||||
kind: syms.kind(i),
|
||||
container: syms.container(i),
|
||||
range: syms.range(i),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, mut entry: FileEntry, symbols: Vec<StoredSymbol>) {
|
||||
let sym_len = symbols.len() as u32;
|
||||
if let Some(&slot) = self.by_path.get(&entry.path) {
|
||||
let old = self.files[slot as usize].replace(entry);
|
||||
self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32);
|
||||
self.symbol_count += symbols;
|
||||
let old = self.files[slot as usize].as_ref().expect("indexed slots are live");
|
||||
let old_len = old.sym_len;
|
||||
// Rescans and saves that re-parse to the same symbols reuse the range; a
|
||||
// same-path replacement then costs nothing in the symbol table.
|
||||
let (sym_start, sym_mask) = if self.syms.same(old.sym_start, old_len, &symbols) {
|
||||
(old.sym_start, self.hot.rows[slot as usize].sym_mask)
|
||||
} else {
|
||||
self.syms.free(old.sym_start, old_len);
|
||||
self.syms.push(&symbols)
|
||||
};
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = sym_len;
|
||||
self.symbol_count = self.symbol_count - old_len + sym_len;
|
||||
self.hot.update(slot, &entry, sym_mask);
|
||||
self.files[slot as usize] = Some(entry);
|
||||
self.compact_if_needed();
|
||||
return;
|
||||
}
|
||||
let (sym_start, sym_mask) = self.syms.push(&symbols);
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = sym_len;
|
||||
let slot = match self.free.pop() {
|
||||
Some(slot) => {
|
||||
self.files[slot as usize] = Some(entry);
|
||||
slot
|
||||
}
|
||||
Some(slot) => slot,
|
||||
None => {
|
||||
self.files.push(Some(entry));
|
||||
self.files.push(None);
|
||||
(self.files.len() - 1) as u32
|
||||
}
|
||||
};
|
||||
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
|
||||
let path = entry.path.clone();
|
||||
let (name_start, name_mask, rel_mask) = (entry.name_start, entry.name_mask, entry.rel_mask);
|
||||
let rel = self.hot.append(&entry.rel);
|
||||
self.hot.set(
|
||||
slot,
|
||||
FileRow {
|
||||
rel,
|
||||
name_start,
|
||||
sym_start: entry.sym_start,
|
||||
sym_len,
|
||||
sym_mask,
|
||||
rel_mask,
|
||||
name_mask,
|
||||
dir_mask: char_mask(&entry.rel[..name_start as usize]),
|
||||
ascii: entry.rel.is_ascii(),
|
||||
live: true,
|
||||
},
|
||||
);
|
||||
self.files[slot as usize] = Some(entry);
|
||||
if let Some(e) = &self.files[slot as usize] {
|
||||
for (rel, abs) in ancestors(e) {
|
||||
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
|
||||
if let Some(dir) = self.dirs.get_mut(rel) {
|
||||
dir.1 += 1;
|
||||
} else {
|
||||
let row = self.dirs_hot.push(rel, abs);
|
||||
self.dirs.insert(rel.into(), (abs.to_owned(), 1, row));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.by_path.insert(path, slot);
|
||||
self.file_count += 1;
|
||||
self.symbol_count += symbols;
|
||||
self.symbol_count += sym_len;
|
||||
self.compact_if_needed();
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, path: &Path) -> Option<FileEntry> {
|
||||
let slot = self.by_path.remove(path)?;
|
||||
let old = self.files[slot as usize].take()?;
|
||||
self.free.push(slot);
|
||||
self.hot.remove(slot);
|
||||
self.syms.free(old.sym_start, old.sym_len);
|
||||
for (rel, _) in ancestors(&old) {
|
||||
if let Some(d) = self.dirs.get_mut(rel) {
|
||||
d.1 -= 1;
|
||||
if d.1 == 0 {
|
||||
self.dirs.remove(rel);
|
||||
}
|
||||
let Some(dir) = self.dirs.get_mut(rel) else { continue };
|
||||
dir.1 -= 1;
|
||||
if dir.1 > 0 {
|
||||
continue;
|
||||
}
|
||||
let Some((_, _, row)) = self.dirs.remove(rel) else { continue };
|
||||
self.dirs_hot.remove(row, &mut self.dirs);
|
||||
}
|
||||
self.file_count -= 1;
|
||||
self.symbol_count -= old.symbols.len() as u32;
|
||||
self.symbol_count -= old.sym_len;
|
||||
self.compact_if_needed();
|
||||
Some(old)
|
||||
}
|
||||
|
||||
@@ -213,6 +680,19 @@ impl IndexData {
|
||||
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
|
||||
}
|
||||
|
||||
/// Reclaims the space of replaced/removed files once it dominates a table.
|
||||
fn compact_if_needed(&mut self) {
|
||||
if self.hot.dead_text > 1 << 16 && self.hot.dead_text * 2 > self.hot.text.len() {
|
||||
self.hot.compact();
|
||||
}
|
||||
if self.syms.dead_rows > 4096 && self.syms.dead_rows * 2 > self.syms.head.len() {
|
||||
self.syms.compact(&mut self.hot.rows, &mut self.files);
|
||||
}
|
||||
if self.dirs_hot.dead > 1 << 16 && self.dirs_hot.dead * 2 > self.dirs_hot.text.len() {
|
||||
self.dirs_hot.compact();
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases spare capacity (after large deletions).
|
||||
pub(crate) fn shrink(&mut self) {
|
||||
while matches!(self.files.last(), Some(None)) {
|
||||
@@ -221,6 +701,11 @@ impl IndexData {
|
||||
self.free.retain(|&s| s != last);
|
||||
}
|
||||
self.files.shrink_to_fit();
|
||||
self.hot.rows.truncate(self.files.len());
|
||||
self.hot.shrink();
|
||||
self.dirs_hot.shrink();
|
||||
self.syms.compact(&mut self.hot.rows, &mut self.files);
|
||||
self.syms.shrink();
|
||||
self.free.shrink_to_fit();
|
||||
self.by_path.shrink_to_fit();
|
||||
self.dirs.shrink_to_fit();
|
||||
@@ -248,7 +733,7 @@ fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(path: &str, symbols: usize) -> FileEntry {
|
||||
fn entry(path: &str, symbols: usize) -> (FileEntry, Vec<StoredSymbol>) {
|
||||
let record = FileRecord {
|
||||
symbols: (0..symbols)
|
||||
.map(|i| StoredSymbol {
|
||||
@@ -263,48 +748,101 @@ mod tests {
|
||||
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
|
||||
}
|
||||
|
||||
fn insert(d: &mut IndexData, path: &str, symbols: usize) {
|
||||
let (e, s) = entry(path, symbols);
|
||||
d.insert(e, s);
|
||||
}
|
||||
|
||||
/// The compact tables must always mirror the entries they were derived from.
|
||||
fn assert_consistent(d: &IndexData) {
|
||||
assert_eq!(d.hot.rows.len(), d.files.len(), "one hot row per slot");
|
||||
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), d.file_count as usize, "live rows are files");
|
||||
for (slot, e) in d.files.iter().enumerate() {
|
||||
let Some(e) = e else {
|
||||
assert!(d.hot.row(slot).is_none(), "free slot {slot} has no row");
|
||||
continue;
|
||||
};
|
||||
let row = d.hot.row(slot).unwrap_or_else(|| panic!("slot {slot} has a row"));
|
||||
assert_eq!(row.rel(d.hot.text()), &*e.rel);
|
||||
assert_eq!(row.name(d.hot.text()), e.name());
|
||||
assert_eq!(row.dir(d.hot.text()), e.dir());
|
||||
assert_eq!(row.name_mask, char_mask(e.name()));
|
||||
assert_eq!(row.rel_mask, char_mask(&e.rel));
|
||||
assert_eq!(row.dir_mask, char_mask(e.dir()));
|
||||
assert_eq!(row.ascii, e.rel.is_ascii());
|
||||
assert_eq!(row.sym_start, e.sym_start);
|
||||
assert_eq!(row.sym_len, e.sym_len);
|
||||
assert_eq!(row.sym_mask, d.symbols_of(e).fold(0u64, |m, s| m | char_mask(s.name)));
|
||||
assert_eq!(d.syms.head.len(), d.syms.container.len());
|
||||
assert_eq!(d.syms.head.len(), d.syms.kind.len());
|
||||
assert_eq!(d.syms.head.len(), d.syms.range.len());
|
||||
assert!(e.sym_len == 0 || (e.sym_start as usize) < d.syms.head.len());
|
||||
assert!(e.sym_start + e.sym_len <= d.syms.head.len() as u32);
|
||||
}
|
||||
for (rel, (path, count, row)) in &d.dirs {
|
||||
let row = &d.dirs_hot.rows[*row as usize];
|
||||
assert_eq!(row.rel(d.dirs_hot.text()), &**rel);
|
||||
assert_eq!(&row.path, path);
|
||||
assert!(*count > 0);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name = &trimmed[row.name_start as usize..];
|
||||
assert_eq!(row.name_mask, char_mask(name));
|
||||
assert_eq!(row.rel_mask, char_mask(trimmed));
|
||||
}
|
||||
assert_eq!(d.dirs.len(), d.dirs_hot.rows.len());
|
||||
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_follow_inserts_and_removals() {
|
||||
let mut d = IndexData::default();
|
||||
d.insert(entry("a/b/c.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("a/d.rs", 0));
|
||||
d.insert(entry("top.rs", 0));
|
||||
insert(&mut d, "a/b/c.rs", 0);
|
||||
insert(&mut d, "a/d.rs", 0);
|
||||
insert(&mut d, "a/d.rs", 0);
|
||||
insert(&mut d, "top.rs", 0);
|
||||
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
|
||||
dirs.sort();
|
||||
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
|
||||
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
|
||||
assert_consistent(&d);
|
||||
d.remove(Path::new("/r/a/b/c.rs"));
|
||||
assert!(!d.dirs.contains_key("a/b/"));
|
||||
assert_consistent(&d);
|
||||
d.remove_tree(Path::new("/r/a"));
|
||||
assert!(d.dirs.is_empty());
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_replace_remove_keep_counts() {
|
||||
let mut d = IndexData::default();
|
||||
d.insert(entry("a/b.rs", 2));
|
||||
d.insert(entry("c.rs", 1));
|
||||
insert(&mut d, "a/b.rs", 2);
|
||||
insert(&mut d, "c.rs", 1);
|
||||
assert_eq!((d.file_count, d.symbol_count), (2, 3));
|
||||
d.insert(entry("a/b.rs", 5));
|
||||
insert(&mut d, "a/b.rs", 5);
|
||||
assert_eq!((d.file_count, d.symbol_count), (2, 6));
|
||||
assert_consistent(&d);
|
||||
assert!(d.remove(Path::new("/r/c.rs")).is_some());
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 5));
|
||||
d.insert(entry("d.rs", 0));
|
||||
assert_consistent(&d);
|
||||
insert(&mut d, "d.rs", 0);
|
||||
assert_eq!(d.files.len(), 2, "slot reused");
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 0));
|
||||
let e = d.get(Path::new("/r/d.rs")).unwrap();
|
||||
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
|
||||
d.insert(entry("e.rs", 1));
|
||||
d.insert(entry("f.rs", 1));
|
||||
insert(&mut d, "e.rs", 1);
|
||||
insert(&mut d, "f.rs", 1);
|
||||
d.remove(Path::new("/r/e.rs"));
|
||||
d.remove(Path::new("/r/f.rs"));
|
||||
d.shrink();
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.files.len(), 2, "trailing free slot dropped");
|
||||
assert_eq!(d.free, vec![0]);
|
||||
d.insert(entry("g.rs", 1));
|
||||
insert(&mut d, "g.rs", 1);
|
||||
assert_eq!((d.file_count, d.files.len()), (2, 2));
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,4 +850,65 @@ mod tests {
|
||||
assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab"));
|
||||
assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z"));
|
||||
}
|
||||
|
||||
fn insert_symbols(d: &mut IndexData, path: &str, symbols: Vec<StoredSymbol>) {
|
||||
let record = FileRecord { symbols, ..Default::default() };
|
||||
let (e, s) = FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record);
|
||||
d.insert(e, s);
|
||||
}
|
||||
|
||||
fn symbol(n: &str) -> StoredSymbol {
|
||||
StoredSymbol { name: n.to_owned(), kind: SymbolKind::Function, container: None, range: Range::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_keeps_entries_and_records_in_sync() {
|
||||
let mut d = IndexData::default();
|
||||
insert(&mut d, "a.rs", 3);
|
||||
insert(&mut d, "b/c.rs", 2);
|
||||
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
|
||||
d.shrink();
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.syms.head.len() as u32, d.symbol_count, "compaction drops dead symbol rows");
|
||||
let names = |d: &IndexData, p: &str| -> Vec<String> {
|
||||
let e = d.get(Path::new(p)).unwrap();
|
||||
d.record(e).symbols.into_iter().map(|s| s.name).collect()
|
||||
};
|
||||
assert_eq!(names(&d, "/r/a.rs"), ["fresh"]);
|
||||
assert_eq!(names(&d, "/r/b/c.rs"), ["s0", "s1"]);
|
||||
assert_eq!(d.symbol_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_replacements_reclaim_symbol_storage() {
|
||||
let mut d = IndexData::default();
|
||||
insert_symbols(&mut d, "a.rs", (0..5_000).map(|i| symbol(&format!("old{i}"))).collect());
|
||||
assert_eq!(d.syms.head.len(), 5_000);
|
||||
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
|
||||
assert_eq!(d.syms.head.len(), 1, "dead symbol rows are reclaimed");
|
||||
assert_eq!((d.symbol_count, d.file_count), (1, 1));
|
||||
assert_consistent(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_removal_leaves_no_hot_rows_or_dirs() {
|
||||
let mut d = IndexData::default();
|
||||
insert(&mut d, "a/b/one.rs", 2);
|
||||
insert(&mut d, "a/b/two.rs", 3);
|
||||
insert(&mut d, "a/three.rs", 1);
|
||||
insert(&mut d, "elsewhere/four.rs", 1);
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")).len(), 3);
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), 1);
|
||||
assert!(d.hot.row(0).is_none(), "removed slots have no live row");
|
||||
assert_eq!(d.dirs.len(), 1, "elsewhere/ only");
|
||||
assert_eq!(d.dirs_hot.rows.len(), 1);
|
||||
assert_eq!(d.symbol_count, 1);
|
||||
d.shrink();
|
||||
assert_eq!(d.syms.head.len() as u32, d.symbol_count);
|
||||
// Slots are reusable and the new row replaces the tombstone.
|
||||
insert(&mut d, "a/five.rs", 2);
|
||||
assert_consistent(&d);
|
||||
assert_eq!(d.file_count, 2);
|
||||
}
|
||||
}
|
||||
@@ -199,12 +199,11 @@ impl ProjectIndex {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
file.symbols
|
||||
.iter()
|
||||
data.symbols_of(file)
|
||||
.map(|s| ide_language::SymbolTag {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_ref().map(ToString::to_string),
|
||||
container: s.container.map(ToString::to_string),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
@@ -231,7 +230,7 @@ impl ProjectIndex {
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
let d = self.shared.data.read();
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
matching::find_files(&d, &matching::Recents::new(&recent), query, limit)
|
||||
}
|
||||
|
||||
/// All indexed files with this exact basename, for resolving printed source locations.
|
||||
@@ -274,7 +273,7 @@ impl ProjectIndex {
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
let d = self.shared.data.read();
|
||||
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
|
||||
matching::find_symbols(&d, &matching::Recents::new(&recent), query, filter, limit)
|
||||
}
|
||||
|
||||
/// Scoped symbol search with cooperative interruption, including lock waits.
|
||||
@@ -303,15 +302,7 @@ impl ProjectIndex {
|
||||
break data;
|
||||
}
|
||||
};
|
||||
matching::find_symbols_until(
|
||||
&data,
|
||||
&matching::Recents { paths: &recent },
|
||||
query,
|
||||
filter,
|
||||
limit,
|
||||
include,
|
||||
stopped,
|
||||
)
|
||||
matching::find_symbols_until(&data, &matching::Recents::new(&recent), query, filter, limit, include, stopped)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
|
||||
@@ -523,7 +514,8 @@ pub mod bench_support {
|
||||
})
|
||||
.collect();
|
||||
let record = FileRecord { symbols, ..Default::default() };
|
||||
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
|
||||
let (entry, symbols) = FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record);
|
||||
data.insert(entry, symbols);
|
||||
}
|
||||
let recent = data.paths().take(MAX_RECENT).cloned().collect();
|
||||
Self { data, recent }
|
||||
@@ -538,7 +530,7 @@ pub mod bench_support {
|
||||
}
|
||||
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
|
||||
matching::find_files(&self.data, &matching::Recents::new(&self.recent), query, limit)
|
||||
}
|
||||
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
@@ -546,7 +538,7 @@ pub mod bench_support {
|
||||
}
|
||||
|
||||
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
||||
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
|
||||
matching::find_symbols(&self.data, &matching::Recents::new(&self.recent), query, filter, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,19 @@
|
||||
//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best
|
||||
//! few; the survivors then get match indices, CamelHump and recency boosts and
|
||||
//! are re-sorted.
|
||||
//!
|
||||
//! The scan streams the compact tables in `data` (`HotFiles`/`HotDirs`/`SymTable`):
|
||||
//! only the hot fields per candidate and strings from contiguous arenas, with the
|
||||
//! thread-local matcher and one UTF-32 buffer per scan rather than per candidate.
|
||||
|
||||
use crate::data::{FileEntry, IndexData, char_mask};
|
||||
use crate::data::{DirRow, FileEntry, FileRow, IndexData, SymTable, char_mask};
|
||||
use crate::{FileMatch, SymbolFilter, SymbolMatch};
|
||||
use ide_core::SymbolKind;
|
||||
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
|
||||
use rayon::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TIER_NAME: u32 = 1 << 20;
|
||||
@@ -54,6 +59,11 @@ impl Needle {
|
||||
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
|
||||
}
|
||||
|
||||
/// `hay` is pure ASCII: skip `Utf32Str::new`'s check and UTF-32 conversion.
|
||||
fn score_ascii(&self, m: &mut Matcher, hay: &str) -> Option<u32> {
|
||||
m.fuzzy_match(Utf32Str::Ascii(hay.as_bytes()), self.utf32.slice(..)).map(u32::from)
|
||||
}
|
||||
|
||||
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
|
||||
out.clear();
|
||||
let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from);
|
||||
@@ -105,6 +115,9 @@ struct Cand {
|
||||
slot: u32,
|
||||
sym: u32,
|
||||
score: u32,
|
||||
/// Candidate name/rel length: the first tie-break key, cached so ranking a pool
|
||||
/// does not chase it through the tables.
|
||||
len: u32,
|
||||
}
|
||||
|
||||
fn keep_best(mut v: Vec<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
|
||||
@@ -119,7 +132,7 @@ fn scan(
|
||||
data: &IndexData,
|
||||
pool: usize,
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
|
||||
) -> Vec<Cand> {
|
||||
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
|
||||
}
|
||||
@@ -129,18 +142,22 @@ fn scan_until(
|
||||
pool: usize,
|
||||
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
|
||||
stopped: &(impl Fn() -> bool + Sync),
|
||||
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
|
||||
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
|
||||
) -> Option<Vec<Cand>> {
|
||||
data.files
|
||||
// One row per slot, so this is exactly the old scan over `files`: same item
|
||||
// order and count, and free slots are skipped exactly like `None` was, keeping
|
||||
// the pruning boundaries (and therefore the candidate pool) unchanged.
|
||||
data.hot
|
||||
.rows
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.with_min_len(256)
|
||||
.try_fold(Vec::new, |mut acc, (slot, e)| {
|
||||
.try_fold(Vec::new, |mut acc, (slot, row)| {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = e {
|
||||
per_file(slot as u32, e, &mut acc);
|
||||
if row.live {
|
||||
with_matcher(|m| per_file(slot as u32, row, m, &mut acc));
|
||||
if acc.len() >= pool * 2 {
|
||||
acc = keep_best(acc, pool, tiebreak);
|
||||
}
|
||||
@@ -155,15 +172,22 @@ fn scan_until(
|
||||
|
||||
pub(crate) struct Recents<'a> {
|
||||
pub paths: &'a [PathBuf],
|
||||
/// Position of each path, so ranking a survivor does not scan the list.
|
||||
/// Duplicates keep their first position, like `position`.
|
||||
ranks: HashMap<&'a Path, usize>,
|
||||
}
|
||||
|
||||
impl Recents<'_> {
|
||||
fn rank(&self, path: &std::path::Path) -> Option<usize> {
|
||||
self.paths.iter().position(|p| p == path)
|
||||
impl<'a> Recents<'a> {
|
||||
pub(crate) fn new(paths: &'a [PathBuf]) -> Self {
|
||||
let mut ranks = HashMap::with_capacity(paths.len());
|
||||
for (i, p) in paths.iter().enumerate() {
|
||||
ranks.entry(p.as_path()).or_insert(i);
|
||||
}
|
||||
Self { paths, ranks }
|
||||
}
|
||||
|
||||
fn bonus(&self, path: &std::path::Path) -> u32 {
|
||||
self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12))
|
||||
fn bonus(&self, path: &Path) -> u32 {
|
||||
self.ranks.get(path).map_or(0, |&r| RECENT_MAX.saturating_sub(r as u32 * 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,29 +233,31 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
|
||||
Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) },
|
||||
None => FileQuery::Name(Needle::new(&q)),
|
||||
};
|
||||
let tiebreak = |a: &Cand, b: &Cand| {
|
||||
let (ea, eb) = (file(data, a.slot), file(data, b.slot));
|
||||
ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel))
|
||||
};
|
||||
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
|
||||
let pool = limit * 4 + 64;
|
||||
let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| {
|
||||
let mut cands = scan(data, pool, &tiebreak, |slot, row, m, acc| {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
|
||||
acc.push(Cand { slot, sym: u32::MAX, score });
|
||||
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
|
||||
acc.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
|
||||
}
|
||||
});
|
||||
// Recent files always compete for the final ranking.
|
||||
for p in recents.paths {
|
||||
if let Some(e) = data.get(p) {
|
||||
with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
for p in recents.paths {
|
||||
if data.get(p).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = data.slot(p) else { continue };
|
||||
if !cands.iter().any(|c| c.slot == slot) {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
|
||||
cands.push(Cand { slot, sym: u32::MAX, score });
|
||||
}
|
||||
if cands.iter().any(|c| c.slot == slot) {
|
||||
continue;
|
||||
}
|
||||
let Some(row) = data.hot.row_of_slot(slot) else { continue };
|
||||
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
|
||||
cands.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut out: Vec<(u32, &FileEntry, Vec<u32>)> = with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
cands
|
||||
@@ -273,30 +299,46 @@ pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<File
|
||||
}
|
||||
let by_path = q.contains('/');
|
||||
let needle = Needle::new(q.trim_end_matches('/'));
|
||||
let text = data.dirs_hot.text();
|
||||
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
|
||||
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
|
||||
let name = &trimmed[start..];
|
||||
if !by_path
|
||||
&& needle.mask & char_mask(name) == needle.mask
|
||||
&& let Some(s) = needle.score(m, name, buf)
|
||||
{
|
||||
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
|
||||
}
|
||||
if needle.mask & char_mask(trimmed) != needle.mask {
|
||||
return None;
|
||||
}
|
||||
needle.score(m, trimmed, buf).map(|s| (s, None))
|
||||
};
|
||||
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
|
||||
.dirs
|
||||
let (mut best, _): (Vec<_>, Vec<char>) = data
|
||||
.dirs_hot
|
||||
.rows
|
||||
.par_iter()
|
||||
.filter_map(|(rel, (path, _))| {
|
||||
let mut buf = Vec::new();
|
||||
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
|
||||
})
|
||||
.collect();
|
||||
.fold(
|
||||
|| (Vec::new(), Vec::new()),
|
||||
|(mut found, mut buf), row: &DirRow| {
|
||||
let rel = row.rel(text);
|
||||
let trimmed = &rel[..rel.len() - 1];
|
||||
let name = &trimmed[row.name_start as usize..];
|
||||
with_matcher(|m| {
|
||||
let hit = if !by_path
|
||||
&& needle.mask & row.name_mask == needle.mask
|
||||
&& let Some(s) = needle.score(m, name, &mut buf)
|
||||
{
|
||||
Some((
|
||||
TIER_NAME + s * 4 + needle.name_boost(name),
|
||||
Some(trimmed[..row.name_start as usize].chars().count() as u32),
|
||||
))
|
||||
} else if needle.mask & row.rel_mask != needle.mask {
|
||||
None
|
||||
} else {
|
||||
needle.score(m, trimmed, &mut buf).map(|s| (s, None))
|
||||
};
|
||||
if let Some((s, at)) = hit {
|
||||
found.push((s, rel, row.path.as_path(), at));
|
||||
}
|
||||
});
|
||||
(found, buf)
|
||||
},
|
||||
)
|
||||
.reduce(
|
||||
|| (Vec::new(), Vec::new()),
|
||||
|mut a, b| {
|
||||
a.0.extend(b.0);
|
||||
a
|
||||
},
|
||||
);
|
||||
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
|
||||
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
|
||||
};
|
||||
@@ -338,33 +380,39 @@ fn file(data: &IndexData, slot: u32) -> &FileEntry {
|
||||
data.files[slot as usize].as_ref().expect("candidate slots are live")
|
||||
}
|
||||
|
||||
fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec<char>) -> Option<u32> {
|
||||
fn base_file_score(m: &mut Matcher, q: &FileQuery, row: &FileRow, text: &str, buf: &mut Vec<char>) -> Option<u32> {
|
||||
// Strings are sliced only once their mask passes, so a rejected row costs two masks.
|
||||
match q {
|
||||
FileQuery::Name(n) => {
|
||||
if n.mask & e.name_mask == n.mask
|
||||
&& let Some(s) = n.score(m, e.name(), buf)
|
||||
{
|
||||
return Some(TIER_NAME + s * 4 + n.name_boost(e.name()));
|
||||
if n.mask & row.name_mask == n.mask {
|
||||
let name = row.name(text);
|
||||
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) };
|
||||
if let Some(s) = s {
|
||||
return Some(TIER_NAME + s * 4 + n.name_boost(name));
|
||||
}
|
||||
}
|
||||
if n.mask & e.rel_mask == n.mask {
|
||||
return n.score(m, &e.rel, buf);
|
||||
if n.mask & row.rel_mask == n.mask {
|
||||
let rel = row.rel(text);
|
||||
return if row.ascii { n.score_ascii(m, rel) } else { n.score(m, rel, buf) };
|
||||
}
|
||||
None
|
||||
}
|
||||
FileQuery::Path { dir, name } => {
|
||||
let name_score = if name.is_empty() {
|
||||
FileQuery::Path { dir, name: n } => {
|
||||
let name_score = if n.is_empty() {
|
||||
0
|
||||
} else {
|
||||
if name.mask & e.name_mask != name.mask {
|
||||
if n.mask & row.name_mask != n.mask {
|
||||
return None;
|
||||
}
|
||||
name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name())
|
||||
let name = row.name(text);
|
||||
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) }?;
|
||||
s * 4 + n.name_boost(name)
|
||||
};
|
||||
let dir_str = e.dir();
|
||||
if dir.mask & char_mask(dir_str) != dir.mask {
|
||||
if dir.mask & row.dir_mask != dir.mask {
|
||||
return None;
|
||||
}
|
||||
let dir_score = dir.score(m, dir_str, buf)?;
|
||||
let dir_str = row.dir(text);
|
||||
let dir_score = if row.ascii { dir.score_ascii(m, dir_str) } else { dir.score(m, dir_str, buf) }?;
|
||||
Some(TIER_NAME + name_score + dir_score)
|
||||
}
|
||||
}
|
||||
@@ -396,22 +444,21 @@ fn symbol_score(
|
||||
m: &mut Matcher,
|
||||
name: &Needle,
|
||||
container: Option<&Needle>,
|
||||
s: &crate::data::Sym,
|
||||
syms: &SymTable,
|
||||
i: u32,
|
||||
buf: &mut Vec<char>,
|
||||
) -> Option<u32> {
|
||||
if name.mask & s.mask != name.mask {
|
||||
return None;
|
||||
}
|
||||
let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 };
|
||||
score += name.name_boost(&s.name);
|
||||
let symbol = syms.name(i);
|
||||
let mut score = if name.is_empty() { 0 } else { name.score(m, symbol, buf)? * 4 };
|
||||
score += name.name_boost(symbol);
|
||||
if let Some(cq) = container {
|
||||
let c = s.container.as_deref()?;
|
||||
let c = syms.container(i)?;
|
||||
if cq.mask & char_mask(c) != cq.mask {
|
||||
return None;
|
||||
}
|
||||
score += cq.score(m, c, buf)? + cq.name_boost(c) / 4;
|
||||
}
|
||||
if s.kind.is_type_like() {
|
||||
if syms.kind(i).is_type_like() {
|
||||
score += TYPE_LIKE;
|
||||
}
|
||||
Some(score)
|
||||
@@ -455,30 +502,35 @@ pub(crate) fn find_symbols_until(
|
||||
let (container_q, name_q) = split_symbol_query(&q);
|
||||
let name = Needle::new(name_q);
|
||||
let container = container_q.map(Needle::new);
|
||||
let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize];
|
||||
let tiebreak = |a: &Cand, b: &Cand| {
|
||||
let (sa, sb) = (sym(a), sym(b));
|
||||
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
|
||||
};
|
||||
let syms = &data.syms;
|
||||
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
|
||||
let pool = limit * 4 + 64;
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
|
||||
if e.symbols.is_empty() || !include(&e.path) {
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, row, m, acc| {
|
||||
// Whole-file pre-filter: when the union of the file's symbol masks
|
||||
// already misses a query character, no symbol can match.
|
||||
if row.sym_len == 0 || name.mask & row.sym_mask != name.mask {
|
||||
return;
|
||||
}
|
||||
with_matcher(|m| {
|
||||
let mut buf = Vec::new();
|
||||
for (i, s) in e.symbols.iter().enumerate() {
|
||||
if stopped() {
|
||||
break;
|
||||
}
|
||||
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
|
||||
continue;
|
||||
}
|
||||
if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) {
|
||||
acc.push(Cand { slot, sym: i as u32, score });
|
||||
}
|
||||
let e = file(data, slot);
|
||||
if !include(&e.path) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
for (k, head) in syms.heads(row.sym_start, row.sym_len).iter().enumerate() {
|
||||
if stopped() {
|
||||
break;
|
||||
}
|
||||
});
|
||||
if filter == SymbolFilter::Types && !syms.kind(row.sym_start + k as u32).is_type_like() {
|
||||
continue;
|
||||
}
|
||||
if name.mask & head.mask != name.mask {
|
||||
continue;
|
||||
}
|
||||
let i = row.sym_start + k as u32;
|
||||
if let Some(score) = symbol_score(m, &name, container.as_ref(), syms, i, &mut buf) {
|
||||
acc.push(Cand { slot, sym: i, score, len: head.name.len });
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if stopped() {
|
||||
return None;
|
||||
@@ -489,33 +541,32 @@ pub(crate) fn find_symbols_until(
|
||||
.into_iter()
|
||||
.take_while(|_| !stopped())
|
||||
.filter_map(|c| {
|
||||
let s = sym(&c);
|
||||
let symbol = syms.name(c.sym);
|
||||
let e = file(data, c.slot);
|
||||
let mut idx = Vec::new();
|
||||
if !name.is_empty() {
|
||||
name.indices(m, &s.name, &mut buf, &mut idx)?;
|
||||
name.indices(m, symbol, &mut buf, &mut idx)?;
|
||||
}
|
||||
let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4;
|
||||
let bonus = hump_bonus(symbol, &idx) + recents.bonus(&e.path) / 4;
|
||||
Some((c.score + bonus, c, idx))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
out.sort_by(|a, b| {
|
||||
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
|
||||
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| syms.range(a.1.sym).cmp(&syms.range(b.1.sym)))
|
||||
});
|
||||
out.truncate(limit);
|
||||
let results = out
|
||||
.into_iter()
|
||||
.map(|(score, c, indices)| {
|
||||
let e = file(data, c.slot);
|
||||
let s = &e.symbols[c.sym as usize];
|
||||
SymbolMatch {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
name: syms.name(c.sym).to_string(),
|
||||
kind: syms.kind(c.sym),
|
||||
container: syms.container(c.sym).map(str::to_owned),
|
||||
path: e.path.clone().into(),
|
||||
rel_path: e.rel.to_string(),
|
||||
range: s.range,
|
||||
range: syms.range(c.sym),
|
||||
score,
|
||||
indices,
|
||||
}
|
||||
@@ -541,24 +592,25 @@ pub(crate) fn symbols_named_within(
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
let syms = &data.syms;
|
||||
let mut out: Vec<SymbolMatch> = data
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|e| within(e))
|
||||
.flat_map(|e| {
|
||||
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
|
||||
move |s| SymbolMatch {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_deref().map(str::to_owned),
|
||||
(e.sym_start..e.sym_start + e.sym_len)
|
||||
.filter(|&i| syms.mask(i) == mask && syms.name(i) == name && kind.is_none_or(|k| syms.kind(i) == k))
|
||||
.map(move |i| SymbolMatch {
|
||||
name: syms.name(i).to_string(),
|
||||
kind: syms.kind(i),
|
||||
container: syms.container(i).map(str::to_owned),
|
||||
path: e.path.clone().into(),
|
||||
rel_path: e.rel.to_string(),
|
||||
range: s.range,
|
||||
range: syms.range(i),
|
||||
score: 0,
|
||||
indices: Vec::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
|
||||
|
||||
@@ -173,7 +173,7 @@ impl Shared {
|
||||
let root = filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> = records
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
@@ -182,8 +182,8 @@ impl Shared {
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
@@ -321,8 +321,7 @@ impl Shared {
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let d = self.data.read();
|
||||
let mut rec = d.get(&f.path)?.record();
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
@@ -338,11 +337,11 @@ impl Shared {
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
||||
{
|
||||
let entries: Vec<FileEntry> =
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for e in entries {
|
||||
d.insert(e);
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
self.persist(&puts, &[], false);
|
||||
|
||||
@@ -79,6 +79,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("scala", include_str!("../../../languages/scala.toml")),
|
||||
("scss", include_str!("../../../languages/scss.toml")),
|
||||
("shellscript", include_str!("../../../languages/shellscript.toml")),
|
||||
("slint", include_str!("../../../languages/slint.toml")),
|
||||
("sql", include_str!("../../../languages/sql.toml")),
|
||||
("svelte", include_str!("../../../languages/svelte.toml")),
|
||||
("swift", include_str!("../../../languages/swift.toml")),
|
||||
@@ -1046,6 +1047,7 @@ mod tests {
|
||||
("icon.svg.import", "godot-resource"),
|
||||
("export_presets.cfg", "godot-resource"),
|
||||
("water.gdshader", "gdshader"),
|
||||
("app-window.slint", "slint"),
|
||||
("notes.txt", "plaintext"),
|
||||
];
|
||||
for (path, id) in cases {
|
||||
|
||||
@@ -809,6 +809,21 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
|
||||
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
|
||||
/// from the old archive. Best-effort: skipped while jdtls is not already running.
|
||||
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
|
||||
let java = LanguageId::new("java");
|
||||
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
|
||||
return Ok(());
|
||||
};
|
||||
if server.running().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
|
||||
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
/// Modification time of the archive behind a cached library class, so a jar
|
||||
/// replaced in place (a SNAPSHOT installed again) is read again.
|
||||
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
@@ -508,6 +511,7 @@ impl LspManager {
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
virtual_stamps: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
@@ -3202,14 +3206,44 @@ impl LspManager {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether the archive behind a cached library class changed since it was read.
|
||||
/// Stats the archive: call from a blocking worker.
|
||||
pub fn library_class_changed(&self, uri: &str) -> bool {
|
||||
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
|
||||
virtual_doc::archive_modified(uri) != Some(stamp)
|
||||
}
|
||||
|
||||
/// Archives behind cached library classes that changed since they were read.
|
||||
/// Stats each archive: call from a blocking worker.
|
||||
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
|
||||
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
|
||||
let mut archives: Vec<PathBuf> = uris
|
||||
.iter()
|
||||
.filter(|uri| self.library_class_changed(uri))
|
||||
.filter_map(|uri| virtual_doc::archive(uri))
|
||||
.collect();
|
||||
archives.sort();
|
||||
archives.dedup();
|
||||
archives
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
let cached = self.virtual_docs.lock().get(uri).cloned();
|
||||
if let Some(d) = cached {
|
||||
let stamp = self.virtual_stamps.lock().get(uri).copied();
|
||||
let current = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
|
||||
};
|
||||
if stamp.is_none() || stamp == current {
|
||||
return Ok(d);
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
@@ -3353,11 +3387,18 @@ impl LspManager {
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let stamp = virtual_doc::archive_modified(uri);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
let mut stamps = self.virtual_stamps.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
stamps.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
match stamp {
|
||||
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
|
||||
None => stamps.remove(uri),
|
||||
};
|
||||
doc
|
||||
}
|
||||
|
||||
|
||||
@@ -445,8 +445,12 @@ impl Server {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&tail);
|
||||
}
|
||||
let delay = self.record_crash();
|
||||
message.push_str(if delay.is_some() {
|
||||
// Starting the same build again can't help: only an update can.
|
||||
let expired = self.reported_expired_build();
|
||||
let delay = if expired { None } else { self.record_crash() };
|
||||
message.push_str(if expired {
|
||||
"\nThis build has expired; update it to start it again."
|
||||
} else if delay.is_some() {
|
||||
"\nRestarting…"
|
||||
} else {
|
||||
"\nCrashed repeatedly; restart it manually."
|
||||
@@ -757,6 +761,12 @@ impl Server {
|
||||
crash_excerpt(&lines, n)
|
||||
}
|
||||
|
||||
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
|
||||
/// exit with "This build of kotlin-server has expired".
|
||||
fn reported_expired_build(&self) -> bool {
|
||||
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
|
||||
}
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
@@ -911,6 +921,10 @@ fn is_error_headline(line: &str) -> bool {
|
||||
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
|
||||
}
|
||||
|
||||
fn is_expired_build_line(line: &str) -> bool {
|
||||
line.contains("This build of ") && line.contains(" has expired")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1071,6 +1085,31 @@ mod tests {
|
||||
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_build_is_not_restarted() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
|
||||
s.push_stderr(
|
||||
generation,
|
||||
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
|
||||
);
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
|
||||
assert_eq!(s.status(), ServerStatus::Crashed);
|
||||
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
|
||||
assert!(matches!(s.state.lock().conn, Conn::Failed));
|
||||
// A manual restart (after an update) starts it again.
|
||||
s.reset_for_restart();
|
||||
assert!(s.begin_start().is_some());
|
||||
|
||||
// Any other crash is restarted.
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "Exception in thread main".into());
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_superseded_start_is_not_announced() {
|
||||
let s = server();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.7";
|
||||
pub const API_VERSION: &str = "1.8";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -1058,6 +1058,9 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
}),
|
||||
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
|
||||
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
|
||||
WorkbenchEvent::ProblemsChanged => {
|
||||
wit_events::Event::Other(wit_types::Extension { kind: "problems-changed".into(), data: "{}".into() })
|
||||
}
|
||||
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
|
||||
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
|
||||
action: a.action,
|
||||
|
||||
@@ -119,11 +119,13 @@ pub enum EventKind {
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
/// Problems of the window's files changed (a language server or a report).
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 16] = [
|
||||
pub const ALL: [Self; 17] = [
|
||||
Self::DocumentChanged,
|
||||
Self::WebsocketReady,
|
||||
Self::FileClosed,
|
||||
@@ -139,6 +141,7 @@ impl EventKind {
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
Self::ProblemsChanged,
|
||||
Self::Scheduled,
|
||||
];
|
||||
|
||||
@@ -159,6 +162,7 @@ impl EventKind {
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::ProblemsChanged => "problems-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
@@ -294,6 +298,8 @@ pub enum WorkbenchEvent {
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
/// Problems of the window's files changed; plugins re-read `problems`.
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
@@ -315,6 +321,7 @@ impl WorkbenchEvent {
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
Self::ProblemsChanged => EventKind::ProblemsChanged,
|
||||
Self::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.7.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.8.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
|
||||
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
|
||||
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
|
||||
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
|
||||
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
|
||||
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
|
||||
@@ -21,6 +21,31 @@ impl Plugin for Fixture {
|
||||
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
|
||||
)
|
||||
.map(|()| None),
|
||||
// The host-owned snapshot, one entry per producer: what `problems` reads.
|
||||
"problems" => {
|
||||
let files: Vec<String> = host::problems(None)?
|
||||
.into_iter()
|
||||
.map(|f| format!("{}:{}:{}", f.path, f.source, f.diagnostics.len()))
|
||||
.collect();
|
||||
Ok(Some(files.join("|")))
|
||||
}
|
||||
// A line-end annotation in the severity styles the editor draws: the host must
|
||||
// keep `typo` and `weak` instead of dropping them as unknown.
|
||||
"decorate" => {
|
||||
let path = arg.unwrap_or_default();
|
||||
let item = |style: &str, after: &str| openidea_plugin::Decoration {
|
||||
range: Range::default(),
|
||||
style: style.into(),
|
||||
hover: None,
|
||||
after: Some(after.into()),
|
||||
};
|
||||
host::set_decorations(
|
||||
path,
|
||||
"fixture",
|
||||
&[item("typo", "typo message"), item("weak", "weak message")],
|
||||
)
|
||||
.map(|()| None)
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
|
||||
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
|
||||
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
|
||||
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
|
||||
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
|
||||
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
|
||||
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
|
||||
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
|
||||
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
|
||||
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
|
||||
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
|
||||
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
|
||||
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
|
||||
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
|
||||
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.7.0;
|
||||
export openidea:plugin/providers@1.7.0;
|
||||
export openidea:plugin/file-actions@1.7.0;
|
||||
include openidea:plugin/plugin-syntax@1.8.0;
|
||||
export openidea:plugin/providers@1.8.0;
|
||||
export openidea:plugin/file-actions@1.8.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
|
||||
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
|
||||
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
|
||||
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
|
||||
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
|
||||
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
|
||||
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
|
||||
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
|
||||
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
|
||||
Loaded 100 of 397 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача