дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-11 20:25:41 +02:00
Порівняти коміти
83
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
4256194f9e | ||
|
|
14e4881418 | ||
|
|
55bc093fe5 | ||
|
|
37ba544733 | ||
|
|
0fde75411a | ||
|
|
c96206ce0c | ||
|
|
2e4e5bb088 | ||
|
|
fe69210ef0 | ||
|
|
0cfe0c82fe | ||
|
|
f21d895924 | ||
|
|
e593d07405 | ||
|
|
daa7f96f7e | ||
|
|
4f78a0f3c3 | ||
|
|
7079e48716 | ||
|
|
3e90b20d8d | ||
|
|
9bd38ddca6 | ||
|
|
c99a22c49c | ||
|
|
41262a2878 | ||
|
|
31f84f1261 | ||
|
|
9921ff759b | ||
|
|
18744b530d | ||
|
|
f7eeaa4459 | ||
|
|
a231f02914 | ||
|
|
b5c92181a5 | ||
|
|
3863d4dc75 | ||
|
|
b1bee808dd | ||
|
|
a8be534043 | ||
|
|
b9aaef73d6 | ||
|
|
4b0067993b | ||
|
|
d2b0bd6e75 | ||
|
|
3b7d72bd03 | ||
|
|
74e1a13428 | ||
|
|
3d6e983523 | ||
|
|
38b8b0d8df | ||
|
|
6d82b94b69 | ||
|
|
8ef5132b5e | ||
|
|
a82e0e2ec5 | ||
|
|
377a24375d | ||
|
|
e6e5e4b6c6 | ||
|
|
55fc760abb | ||
|
|
86e8bb5c4d | ||
|
|
afb7c8ea63 | ||
|
|
f7aa81305a | ||
|
|
1a6fc7fd21 | ||
|
|
835a9c883c | ||
|
|
d62e9855b1 | ||
|
|
9660a47958 | ||
|
|
2d404a8664 | ||
|
|
83cbc8ff5c | ||
|
|
eab165b4e0 | ||
|
|
5bdf4835b0 | ||
|
|
1acc007e47 | ||
|
|
727dabe579 | ||
|
|
c70203b640 | ||
|
|
c088fa8b26 | ||
|
|
481cf172b5 | ||
|
|
ad7f03090a | ||
|
|
0e3b35e1e5 | ||
|
|
025e3e9255 | ||
|
|
d78a73452b | ||
|
|
eed2de8d66 | ||
|
|
e3808fc7c2 | ||
|
|
33786c14ba | ||
|
|
51ffd093b7 | ||
|
|
379709f13f | ||
|
|
0311bd3bf8 | ||
|
|
df87c3ba1f | ||
|
|
e8ef07f918 | ||
|
|
6a612a832c | ||
|
|
e09cb2353f | ||
|
|
bf54006f05 | ||
|
|
ad39bda16f | ||
|
|
283b3bbeea | ||
|
|
51e4ec0ab9 | ||
|
|
5593260635 | ||
|
|
f4f648567f | ||
|
|
7e365a3e0f | ||
|
|
7594a98e8c | ||
|
|
839123caf2 | ||
|
|
9153c66849 | ||
|
|
27ee6fde61 | ||
|
|
0574691d7e | ||
|
|
5948f8a7a3 |
No files matched your search
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/l0ng-ai/openidea/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
Створено
+83
-118
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,16 +1578,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1608,20 +1598,6 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1631,7 +1607,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1644,21 +1620,10 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -2685,7 +2650,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4180,7 +4145,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4204,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4218,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4229,7 +4194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4247,7 +4212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4261,7 +4226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4271,7 +4236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4288,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4302,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4323,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
@@ -4333,7 +4298,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4353,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4366,10 +4331,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4386,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4398,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4411,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4477,9 +4443,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4495,7 +4462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4517,7 +4484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4534,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4590,7 +4557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4616,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4635,7 +4602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4658,7 +4625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4681,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4694,7 +4661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
@@ -4713,7 +4680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4737,7 +4704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4757,7 +4724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4777,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4790,7 +4757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4811,7 +4778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4826,7 +4793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4839,7 +4806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4852,7 +4819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4873,7 +4840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4894,7 +4861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4918,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4934,7 +4901,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5751,7 +5718,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6487,15 +6454,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6521,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6609,29 +6567,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
name = "oracledb"
|
||||
version = "26.0.0-beta.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"aes",
|
||||
"base16ct",
|
||||
"base64ct",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"pbkdf2",
|
||||
"pkcs8",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"sha2 0.11.1",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8918,12 +8871,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8960,7 +8907,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9183,7 +9129,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -10723,6 +10669,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -10732,6 +10687,15 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11315,6 +11279,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
+3
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.40"
|
||||
version = "0.1.41"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -106,7 +106,8 @@ rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security problems in public issues, pull requests or discussions.
|
||||
|
||||
Report them privately through GitHub:
|
||||
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
|
||||
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
|
||||
the report.
|
||||
|
||||
Include what you can of:
|
||||
|
||||
- the affected version, platform, and whether remote development (`openidea-server`)
|
||||
or plugins are involved;
|
||||
- steps to reproduce, or a proof of concept;
|
||||
- the impact you expect, and any fix you suggest.
|
||||
|
||||
We will acknowledge the report, keep you updated in the advisory, and agree on a
|
||||
disclosure date with you. Fixes are released as a new version, and the advisory is
|
||||
published once users can update.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Please check that the problem still occurs
|
||||
there before reporting.
|
||||
@@ -1,5 +1,12 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Flutter and Vite project icons
|
||||
|
||||
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
|
||||
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
|
||||
@@ -164,6 +164,11 @@ impl Negotiated {
|
||||
self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// `mcpCapabilities.<name>` (`http`, `sse`).
|
||||
pub fn mcp(&self, name: &str) -> bool {
|
||||
self.capabilities.pointer(&format!("/mcpCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// `sessionCapabilities.<name>` (`fork`, `resume`, `close`, `list`, `delete`).
|
||||
pub fn session(&self, name: &str) -> bool {
|
||||
self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null())
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
mod acp;
|
||||
mod config;
|
||||
mod conversation;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
mod native;
|
||||
mod provider;
|
||||
@@ -91,6 +92,9 @@ type Connector = Arc<
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Returns the configured MCP servers (User and Workspace) for this project.
|
||||
pub type McpServersFn = dyn Fn() -> Vec<McpServer> + Send + Sync;
|
||||
|
||||
struct Shared {
|
||||
paths: AgentPaths,
|
||||
runtime: tokio::runtime::Handle,
|
||||
@@ -104,6 +108,7 @@ struct Shared {
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
|
||||
}
|
||||
|
||||
/// What an agent offered in its last session, for the New Task page.
|
||||
@@ -131,6 +136,13 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_servers_for_session(&self, provider: &str, init: &acp::Negotiated) -> Vec<Value> {
|
||||
let mut servers = mcp_servers_for(init.mcp_stdio(), &self.paths.project_root);
|
||||
let configured = self.mcp_servers_hook.read().as_ref().map(|hook| hook()).unwrap_or_default();
|
||||
servers.extend(mcp::acp_mcp_servers(&configured, provider, init));
|
||||
servers
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
@@ -163,16 +175,25 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok((commands, options)) =
|
||||
if let Ok(found) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
let levels: Vec<_> = found
|
||||
.efforts
|
||||
.into_iter()
|
||||
.map(|(model, o)| {
|
||||
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
|
||||
(model, options.unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.remember_discovered(&spec.id, &options, &commands, &levels);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -193,6 +214,18 @@ impl Shared {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the thinking levels of every model the agent offers are known (#1686).
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| &o.values)
|
||||
.all(|v| known.thought_levels.contains_key(&v.value))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -205,11 +238,19 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
self.remember_inner(provider, config, commands, model, &[], false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
/// `levels`: each model's thinking-level options, so a new task offers them for any model
|
||||
/// it picks (#1686). A session's report for a model wins over these.
|
||||
fn remember_discovered(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: &[AgentConfigOption],
|
||||
commands: &[AgentCommand],
|
||||
levels: &[(String, Vec<AgentConfigOption>)],
|
||||
) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
@@ -218,12 +259,19 @@ impl Shared {
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered_levels: &[(String, Vec<AgentConfigOption>)],
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
for (model, levels) in discovered_levels {
|
||||
if !known.thought_levels.contains_key(model) {
|
||||
known.thought_levels.insert(model.clone(), levels.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
@@ -277,6 +325,9 @@ struct TaskFile {
|
||||
/// In the project's own checkout: files already changed when the task started.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
before: BTreeMap<String, worktree::Stamp>,
|
||||
/// The folder (in the task store) with copies of the `before` files, for rolling back.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
saved: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
subagents: Vec<SavedSubagent>,
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
@@ -293,6 +344,7 @@ struct TaskState {
|
||||
conversation: Conversation,
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
saved: Option<String>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
@@ -315,7 +367,7 @@ struct Live {
|
||||
embedded: bool,
|
||||
/// `session/load`: the process can be stopped between turns and the session reloaded.
|
||||
resumable: bool,
|
||||
mcp_stdio: bool,
|
||||
init: acp::Negotiated,
|
||||
}
|
||||
|
||||
struct Task {
|
||||
@@ -385,6 +437,7 @@ impl AgentManager {
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
mcp_servers_hook: RwLock::new(None),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
|
||||
@@ -425,8 +478,11 @@ impl AgentManager {
|
||||
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
|
||||
pub fn providers(&self) -> Vec<AgentProvider> {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let in_use: std::collections::HashSet<String> =
|
||||
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
|
||||
let list = specs
|
||||
.iter()
|
||||
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
|
||||
.map(|p| {
|
||||
let mut info = provider::describe(p);
|
||||
info.config = self.shared.known_config(&p.id);
|
||||
@@ -472,6 +528,11 @@ impl AgentManager {
|
||||
self.shared.emit(AgentEvent::Providers);
|
||||
}
|
||||
|
||||
/// Provides the user and workspace MCP servers configured in settings.
|
||||
pub fn set_mcp_servers_hook(&self, hook: Box<McpServersFn>) {
|
||||
*self.shared.mcp_servers_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
|
||||
/// in the project: what the agent offered last time there, unadvertised builtins,
|
||||
/// and, for Claude Code, skills and custom commands on disk it has not listed.
|
||||
@@ -488,7 +549,7 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
@@ -576,9 +637,24 @@ impl AgentManager {
|
||||
}));
|
||||
}
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
// Copies of the files the user had changed already, for rolling back the task's changes to them.
|
||||
let saved = if before.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let name = format!("{id}.before");
|
||||
let (dir, at, files) = (self.shared.paths.store_dir.join(&name), cwd.clone(), before.clone());
|
||||
match blocking(move || worktree::save_before(&at, &files, &dir)).await {
|
||||
Ok(saved) => saved.then_some(name),
|
||||
Err(e) => {
|
||||
tracing::warn!("saving the changed files of agent task {id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let task = AgentTask {
|
||||
project_root,
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
id,
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
@@ -606,6 +682,7 @@ impl AgentManager {
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
@@ -773,10 +850,16 @@ impl AgentManager {
|
||||
return Err(IdeError::invalid("this agent can't copy a conversation from there"));
|
||||
}
|
||||
let cwd = source.state.lock().task.cwd.0.clone();
|
||||
let provider_id = source.state.lock().task.provider.clone();
|
||||
let mcp_servers = source.shared.mcp_servers_for_session(&provider_id, &live.init);
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
let reply: Value = live
|
||||
.peer
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
|
||||
.request(
|
||||
"session/fork",
|
||||
json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(source.describe(e)))?;
|
||||
let session = reply
|
||||
@@ -810,6 +893,7 @@ impl AgentManager {
|
||||
entries,
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1073,6 +1157,12 @@ impl AgentManager {
|
||||
self.tasks.write().remove(id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
|
||||
let saved = task.state.lock().saved.clone();
|
||||
if let Some(saved) = saved
|
||||
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
|
||||
}
|
||||
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1091,6 +1181,41 @@ impl AgentManager {
|
||||
blocking(move || worktree::file_at(&cwd, &path, &base)).await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
/// were when it started (files the user had changed then, as the user left them).
|
||||
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let (before, saved) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
|
||||
let mut restamped = Vec::new();
|
||||
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
|
||||
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
|
||||
// Back to the user's own edits: not a change of the task any more.
|
||||
if before.contains_key(&change.rel_path) {
|
||||
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
|
||||
}
|
||||
}
|
||||
Ok(restamped)
|
||||
})
|
||||
.await?;
|
||||
if !restamped.is_empty() {
|
||||
let mut s = task.state.lock();
|
||||
let mut before = (*s.before).clone();
|
||||
before.extend(restamped);
|
||||
s.before = Arc::new(before);
|
||||
}
|
||||
task.persist();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops every agent (project closed).
|
||||
pub fn shutdown(&self) {
|
||||
for task in self.tasks.read().values() {
|
||||
@@ -1116,6 +1241,7 @@ impl Task {
|
||||
conversation,
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
saved: file.saved,
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
@@ -1228,6 +1354,7 @@ impl Task {
|
||||
entries: s.conversation.main.entries.clone(),
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1533,16 +1660,17 @@ impl Task {
|
||||
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
|
||||
let sign_in_message = spec.sign_in_message(&init.auth_methods);
|
||||
let can_load = init.load_session();
|
||||
let mcp_stdio = init.mcp_stdio();
|
||||
let (capabilities, embedded) = capabilities_of(&init);
|
||||
let mcp_servers = self.shared.mcp_servers_for_session(&provider_id, &init);
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
|
||||
let mut session = None;
|
||||
if let (Some(id), true) = (saved, can_load) {
|
||||
self.replaying.store(true, Ordering::Release);
|
||||
let loaded: Result<Value, RpcError> =
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
|
||||
let loaded: Result<Value, RpcError> = peer
|
||||
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
.await;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1555,11 +1683,7 @@ impl Task {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let v: Value = peer
|
||||
.request(
|
||||
"session/new",
|
||||
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
|
||||
None,
|
||||
)
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
.await
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
@@ -1651,7 +1775,7 @@ impl Task {
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live =
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, init };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1722,7 +1846,7 @@ impl Task {
|
||||
format!("The agent process exited:\n{}", tail.join("\n"))
|
||||
}
|
||||
}
|
||||
RpcError::Server { message, .. } => message,
|
||||
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2180,6 +2304,24 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
|
||||
.then_some(None)
|
||||
}
|
||||
|
||||
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
|
||||
/// generic codes such as "Internal error" say nothing without it.
|
||||
fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
let details = match data {
|
||||
Some(Value::String(text)) => text.as_str(),
|
||||
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
|
||||
None => "",
|
||||
}
|
||||
.trim();
|
||||
if details.is_empty() || message.contains(details) {
|
||||
message
|
||||
} else if message.trim().is_empty() {
|
||||
details.to_owned()
|
||||
} else {
|
||||
format!("{message}: {details}")
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Model Context Protocol (MCP) server configurations stored in OpenIDEA's own settings
|
||||
//! (global `settings.toml` and project `.openidea/settings.toml`), passed to ACP agents in
|
||||
//! `session/new`, `session/load`, and `session/fork`, plus one-way import from `.mcp.json` /
|
||||
//! `.vscode/mcp.json`.
|
||||
|
||||
use crate::acp::Negotiated;
|
||||
use crate::{AgentMcpServer, McpKeyValue, McpScope, McpServer, McpServerInput, McpTransport};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
/// Stable ID for a server in a given scope (`"user::<name>"` or `"workspace::<name>"`).
|
||||
pub fn server_id(scope: McpScope, name: &str) -> String {
|
||||
let prefix = match scope {
|
||||
McpScope::User => "user",
|
||||
McpScope::Workspace => "workspace",
|
||||
};
|
||||
format!("{prefix}::{name}")
|
||||
}
|
||||
|
||||
/// Parses a server ID (`"user::<name>"` or `"workspace::<name>"`).
|
||||
pub fn parse_server_id(id: &str) -> Option<(McpScope, &str)> {
|
||||
let (prefix, name) = id.split_once("::")?;
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match prefix {
|
||||
"user" => Some((McpScope::User, name)),
|
||||
"workspace" => Some((McpScope::Workspace, name)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the User (`settings.toml`) and Workspace (`.openidea/settings.toml`) MCP server maps
|
||||
/// into the ordered list displayed in Settings › Agents › MCP Servers.
|
||||
pub fn servers_from_settings(
|
||||
user: &BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &BTreeMap<String, AgentMcpServer>,
|
||||
) -> Vec<McpServer> {
|
||||
user.iter()
|
||||
.map(|(name, cfg)| to_mcp_server(McpScope::User, name, cfg))
|
||||
.chain(workspace.iter().map(|(name, cfg)| to_mcp_server(McpScope::Workspace, name, cfg)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_mcp_server(scope: McpScope, name: &str, cfg: &AgentMcpServer) -> McpServer {
|
||||
McpServer {
|
||||
id: server_id(scope, name),
|
||||
name: name.to_owned(),
|
||||
scope,
|
||||
transport: cfg.transport,
|
||||
enabled: cfg.enabled,
|
||||
command: cfg.command.clone(),
|
||||
args: cfg.args.clone(),
|
||||
env: cfg.env.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
|
||||
url: cfg.url.clone(),
|
||||
headers: cfg.headers.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
|
||||
agents: cfg.agents.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider(id: &str) -> &str {
|
||||
id.strip_suffix("-acp").unwrap_or(id)
|
||||
}
|
||||
|
||||
fn matches_agent(agents: &[String], provider: &str) -> bool {
|
||||
if agents.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let target = normalize_provider(provider);
|
||||
agents.iter().any(|a| {
|
||||
let trimmed = a.trim();
|
||||
trimmed == provider || normalize_provider(trimmed) == target
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the ACP `mcpServers` array for `session/new`, `session/load`, and `session/fork`.
|
||||
/// Workspace entries take precedence over User entries with the same name.
|
||||
pub(crate) fn acp_mcp_servers(all: &[McpServer], provider: &str, init: &Negotiated) -> Vec<Value> {
|
||||
let supports_stdio = init.mcp_stdio();
|
||||
let supports_http = init.mcp("http");
|
||||
let supports_sse = init.mcp("sse");
|
||||
let mut seen = HashSet::new();
|
||||
let mut servers = Vec::new();
|
||||
|
||||
let ordered =
|
||||
all.iter().filter(|s| s.scope == McpScope::Workspace).chain(all.iter().filter(|s| s.scope == McpScope::User));
|
||||
|
||||
for srv in ordered {
|
||||
if !srv.enabled || !matches_agent(&srv.agents, provider) || !seen.insert(srv.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
match srv.transport {
|
||||
McpTransport::Stdio if supports_stdio => {
|
||||
let Some(cmd) = srv.command.as_deref().filter(|c| !c.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let env: Vec<Value> = srv.env.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"name": srv.name,
|
||||
"command": cmd,
|
||||
"args": srv.args,
|
||||
"env": env,
|
||||
}));
|
||||
}
|
||||
McpTransport::Http if supports_http => {
|
||||
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let headers: Vec<Value> =
|
||||
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"type": "http",
|
||||
"name": srv.name,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
}));
|
||||
}
|
||||
McpTransport::Sse if supports_sse => {
|
||||
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let headers: Vec<Value> =
|
||||
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
|
||||
servers.push(json!({
|
||||
"type": "sse",
|
||||
"name": srv.name,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
servers
|
||||
}
|
||||
|
||||
/// Applies a create or update operation to the User / Workspace MCP server maps.
|
||||
pub fn apply_save(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
has_workspace: bool,
|
||||
input: McpServerInput,
|
||||
) -> Result<(), String> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Server name is required".into());
|
||||
}
|
||||
if input.scope == McpScope::Workspace && !has_workspace {
|
||||
return Err("Open a project to configure workspace MCP servers".into());
|
||||
}
|
||||
let cfg = match input.transport {
|
||||
McpTransport::Stdio => {
|
||||
let cmd = input.command.as_deref().map(str::trim).unwrap_or_default();
|
||||
if cmd.is_empty() {
|
||||
return Err("Command is required for stdio MCP servers".into());
|
||||
}
|
||||
let env = input
|
||||
.env
|
||||
.into_iter()
|
||||
.filter_map(|kv| {
|
||||
let k = kv.name.trim();
|
||||
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
|
||||
})
|
||||
.collect();
|
||||
AgentMcpServer {
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: input.enabled,
|
||||
command: Some(cmd.to_owned()),
|
||||
args: input.args,
|
||||
env,
|
||||
url: None,
|
||||
headers: BTreeMap::new(),
|
||||
agents: normalize_agents_list(input.agents),
|
||||
}
|
||||
}
|
||||
McpTransport::Http | McpTransport::Sse => {
|
||||
let url = input.url.as_deref().map(str::trim).unwrap_or_default();
|
||||
if url.is_empty() {
|
||||
return Err("URL is required for HTTP/SSE MCP servers".into());
|
||||
}
|
||||
let headers = input
|
||||
.headers
|
||||
.into_iter()
|
||||
.filter_map(|kv| {
|
||||
let k = kv.name.trim();
|
||||
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
|
||||
})
|
||||
.collect();
|
||||
AgentMcpServer {
|
||||
transport: input.transport,
|
||||
enabled: input.enabled,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
url: Some(url.to_owned()),
|
||||
headers,
|
||||
agents: normalize_agents_list(input.agents),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(prev_id) = input.id.as_deref()
|
||||
&& let Some((prev_scope, prev_name)) = parse_server_id(prev_id)
|
||||
&& (prev_scope != input.scope || prev_name != name)
|
||||
{
|
||||
match prev_scope {
|
||||
McpScope::User => {
|
||||
user.remove(prev_name);
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
workspace.remove(prev_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match input.scope {
|
||||
McpScope::User => {
|
||||
user.insert(name.to_owned(), cfg);
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
workspace.insert(name.to_owned(), cfg);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enables or disables a User or Workspace MCP server by ID.
|
||||
pub fn apply_set_enabled(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<McpScope, String> {
|
||||
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
|
||||
let map = match scope {
|
||||
McpScope::User => user,
|
||||
McpScope::Workspace => workspace,
|
||||
};
|
||||
let srv = map.get_mut(name).ok_or_else(|| format!("MCP server `{name}` not found"))?;
|
||||
srv.enabled = enabled;
|
||||
Ok(scope)
|
||||
}
|
||||
|
||||
/// Removes a User or Workspace MCP server by ID.
|
||||
pub fn apply_remove(
|
||||
user: &mut BTreeMap<String, AgentMcpServer>,
|
||||
workspace: &mut BTreeMap<String, AgentMcpServer>,
|
||||
id: &str,
|
||||
) -> Result<McpScope, String> {
|
||||
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
|
||||
let map = match scope {
|
||||
McpScope::User => user,
|
||||
McpScope::Workspace => workspace,
|
||||
};
|
||||
if map.remove(name).is_none() {
|
||||
return Err(format!("MCP server `{name}` not found"));
|
||||
}
|
||||
Ok(scope)
|
||||
}
|
||||
|
||||
/// Imports MCP servers from `<workspace_root>/.mcp.json` and `<workspace_root>/.vscode/mcp.json`
|
||||
/// into `workspace` without modifying the external files. Returns the number of newly added servers.
|
||||
pub fn apply_import(workspace: &mut BTreeMap<String, AgentMcpServer>, workspace_root: &Path) -> usize {
|
||||
let mut added = 0;
|
||||
for rel in [".mcp.json", ".vscode/mcp.json"] {
|
||||
let path = workspace_root.join(rel);
|
||||
for (name, srv) in read_external_file(&path) {
|
||||
if let std::collections::btree_map::Entry::Vacant(e) = workspace.entry(name) {
|
||||
e.insert(srv);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
fn normalize_agents_list(agents: Vec<String>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for a in agents {
|
||||
let trimmed = a.trim();
|
||||
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
|
||||
out.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_external_file(path: &Path) -> BTreeMap<String, AgentMcpServer> {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Ok(root) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Some(servers_obj) = root.get("mcpServers").or_else(|| root.get("servers")).and_then(Value::as_object) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, def) in servers_obj {
|
||||
let name = name.trim();
|
||||
let Some(obj) = def.as_object().filter(|_| !name.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let disabled = obj.get("disabled").and_then(Value::as_bool).unwrap_or(false)
|
||||
|| obj.get("enabled").and_then(Value::as_bool) == Some(false);
|
||||
let type_str = obj.get("type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
||||
let url = obj
|
||||
.get("url")
|
||||
.or_else(|| obj.get("serverUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
let command =
|
||||
obj.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
|
||||
let transport = match type_str.as_str() {
|
||||
"sse" => McpTransport::Sse,
|
||||
"http" | "streamable-http" | "streamable_http" => McpTransport::Http,
|
||||
"stdio" | "local" => McpTransport::Stdio,
|
||||
_ if url.is_some() && command.is_none() => McpTransport::Http,
|
||||
_ => McpTransport::Stdio,
|
||||
};
|
||||
|
||||
match transport {
|
||||
McpTransport::Stdio if command.is_none() => continue,
|
||||
McpTransport::Http | McpTransport::Sse if url.is_none() => continue,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let args = obj
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
out.insert(
|
||||
name.to_owned(),
|
||||
AgentMcpServer {
|
||||
transport,
|
||||
enabled: !disabled,
|
||||
command,
|
||||
args,
|
||||
env: parse_key_values_map(obj.get("env")),
|
||||
url,
|
||||
headers: parse_key_values_map(obj.get("headers")),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_key_values_map(val: Option<&Value>) -> BTreeMap<String, String> {
|
||||
match val {
|
||||
Some(Value::Object(map)) => map
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let s = match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((k.clone(), s))
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let obj = item.as_object()?;
|
||||
let name = obj.get("name")?.as_str()?.to_owned();
|
||||
let value = obj.get("value")?.as_str()?.to_owned();
|
||||
Some((name, value))
|
||||
})
|
||||
.collect(),
|
||||
_ => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crud_and_acp_filtering_by_scope_agent_and_transport() {
|
||||
let mut user = BTreeMap::new();
|
||||
let mut workspace = BTreeMap::new();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "shared".into(),
|
||||
scope: McpScope::User,
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: Some("npx".into()),
|
||||
args: vec!["-y".into(), "user-pkg".into()],
|
||||
env: vec![McpKeyValue { name: "TOKEN".into(), value: "u1".into() }],
|
||||
url: None,
|
||||
headers: Vec::new(),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "shared".into(),
|
||||
scope: McpScope::Workspace,
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: Some("npx".into()),
|
||||
args: vec!["-y".into(), "workspace-pkg".into()],
|
||||
env: Vec::new(),
|
||||
url: None,
|
||||
headers: Vec::new(),
|
||||
agents: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_save(
|
||||
&mut user,
|
||||
&mut workspace,
|
||||
true,
|
||||
McpServerInput {
|
||||
id: None,
|
||||
name: "claude-http".into(),
|
||||
scope: McpScope::User,
|
||||
transport: McpTransport::Http,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
url: Some("https://example.com/mcp".into()),
|
||||
headers: vec![McpKeyValue { name: "Authorization".into(), value: "Bearer secret".into() }],
|
||||
agents: vec!["claude".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let list = servers_from_settings(&user, &workspace);
|
||||
assert_eq!(list.len(), 3);
|
||||
|
||||
// Negotiated with HTTP support: `claude` gets workspace `shared` + `claude-http`; `codex` only gets `shared`.
|
||||
let init_http = Negotiated::from_initialize(&json!({
|
||||
"agentCapabilities": { "mcpCapabilities": { "http": true, "sse": false } }
|
||||
}));
|
||||
let claude_servers = acp_mcp_servers(&list, "claude-acp", &init_http);
|
||||
assert_eq!(claude_servers.len(), 2);
|
||||
assert_eq!(claude_servers[0]["name"], "shared");
|
||||
assert_eq!(claude_servers[0]["args"], json!(["-y", "workspace-pkg"]));
|
||||
assert_eq!(claude_servers[1]["name"], "claude-http");
|
||||
assert_eq!(claude_servers[1]["type"], "http");
|
||||
|
||||
let codex_servers = acp_mcp_servers(&list, "codex", &init_http);
|
||||
assert_eq!(codex_servers.len(), 1);
|
||||
assert_eq!(codex_servers[0]["name"], "shared");
|
||||
|
||||
// Disable workspace `shared` and remove user `claude-http`.
|
||||
apply_set_enabled(&mut user, &mut workspace, "workspace::shared", false).unwrap();
|
||||
apply_remove(&mut user, &mut workspace, "user::claude-http").unwrap();
|
||||
let list = servers_from_settings(&user, &workspace);
|
||||
let after = acp_mcp_servers(&list, "claude", &init_http);
|
||||
assert_eq!(after.len(), 1);
|
||||
assert_eq!(after[0]["args"], json!(["-y", "user-pkg"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_from_mcp_json_and_vscode_mcp_json_without_modifying_them() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mcp_json = tmp.path().join(".mcp.json");
|
||||
let vscode_dir = tmp.path().join(".vscode");
|
||||
std::fs::create_dir_all(&vscode_dir).unwrap();
|
||||
let vscode_mcp = vscode_dir.join("mcp.json");
|
||||
|
||||
let raw_mcp = r#"{"mcpServers":{"sqlite":{"command":"uvx","args":["mcp-server-sqlite"]}}}"#;
|
||||
let raw_vscode = r#"{"servers":{"remote":{"type":"http","url":"https://mcp.example.com"}}}"#;
|
||||
std::fs::write(&mcp_json, raw_mcp).unwrap();
|
||||
std::fs::write(&vscode_mcp, raw_vscode).unwrap();
|
||||
|
||||
let mut workspace = BTreeMap::new();
|
||||
let added = apply_import(&mut workspace, tmp.path());
|
||||
assert_eq!(added, 2);
|
||||
assert_eq!(workspace["sqlite"].command.as_deref(), Some("uvx"));
|
||||
assert_eq!(workspace["remote"].transport, McpTransport::Http);
|
||||
assert_eq!(workspace["remote"].url.as_deref(), Some("https://mcp.example.com"));
|
||||
|
||||
// Re-importing does not duplicate or overwrite existing entries, and leaves files untouched.
|
||||
workspace.get_mut("sqlite").unwrap().enabled = false;
|
||||
assert_eq!(apply_import(&mut workspace, tmp.path()), 0);
|
||||
assert!(!workspace["sqlite"].enabled);
|
||||
assert_eq!(std::fs::read_to_string(&mcp_json).unwrap(), raw_mcp);
|
||||
assert_eq!(std::fs::read_to_string(&vscode_mcp).unwrap(), raw_vscode);
|
||||
}
|
||||
}
|
||||
@@ -535,3 +535,70 @@ pub enum AgentEvent {
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
|
||||
pub use ide_core::settings::{AgentMcpServer, McpTransport};
|
||||
|
||||
/// Where an MCP server configuration is stored (`settings.toml` vs `.openidea/settings.toml`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpScope {
|
||||
User,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/// A named environment variable or HTTP header for an MCP server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpKeyValue {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// One configured Model Context Protocol (MCP) server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scope: McpScope,
|
||||
pub transport: McpTransport,
|
||||
pub enabled: bool,
|
||||
pub command: Option<String>,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<McpKeyValue>,
|
||||
pub url: Option<String>,
|
||||
pub headers: Vec<McpKeyValue>,
|
||||
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
|
||||
#[serde(default)]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
/// Payload for creating or updating a user or workspace MCP server.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerInput {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub scope: McpScope,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: Vec<McpKeyValue>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: Vec<McpKeyValue>,
|
||||
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
|
||||
#[serde(default)]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -69,9 +69,17 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// What discovery found: ACP `availableCommands`, `configOptions` for the configured model,
|
||||
/// and each model's effort option by model value (`None`: none).
|
||||
pub(crate) struct Discovered {
|
||||
pub commands: Vec<Value>,
|
||||
pub options: Value,
|
||||
pub efforts: Vec<(String, Option<Value>)>,
|
||||
}
|
||||
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discovered, String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
@@ -100,7 +108,11 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Va
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
Ok(Discovered {
|
||||
commands: settings.commands.clone(),
|
||||
options: settings.options(),
|
||||
efforts: settings.efforts_by_model(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
|
||||
@@ -132,19 +132,18 @@ impl Settings {
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default();
|
||||
if !efforts.is_empty() {
|
||||
let mut values = vec![json!({ "value": "default", "name": "Default" })];
|
||||
values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
|
||||
let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" };
|
||||
out.push(json!({
|
||||
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
|
||||
"currentValue": current, "options": values,
|
||||
}));
|
||||
if let Some(effort) = self.current_model().and_then(|m| effort_option(m, &self.effort)) {
|
||||
out.push(effort);
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Each model's effort option by model value (`None`: it has no effort levels): what a new
|
||||
/// task offers once that model is picked, before any session has run it.
|
||||
pub fn efforts_by_model(&self) -> Vec<(String, Option<Value>)> {
|
||||
self.models.iter().map(|m| (m.value.clone(), effort_option(m, &self.effort))).collect()
|
||||
}
|
||||
|
||||
pub fn valid(&self, option: &str, value: &str) -> bool {
|
||||
self.options()
|
||||
.as_array()
|
||||
@@ -156,6 +155,20 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// `model`'s effort option, with `effort` selected when the model offers it; `None` without efforts.
|
||||
fn effort_option(model: &Model, effort: &str) -> Option<Value> {
|
||||
if model.efforts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut values = vec![json!({ "value": "default", "name": "Default" })];
|
||||
values.extend(model.efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
|
||||
let current = if model.efforts.iter().any(|e| e == effort) { effort } else { "default" };
|
||||
Some(json!({
|
||||
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
|
||||
"currentValue": current, "options": values,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal.
|
||||
pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec<Value> {
|
||||
list.and_then(Value::as_array)
|
||||
@@ -231,4 +244,20 @@ mod tests {
|
||||
let cmds = commands(init().get("commands"), &["doctor".into()]);
|
||||
assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_offers_its_own_efforts() {
|
||||
let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}}));
|
||||
let efforts = s.efforts_by_model();
|
||||
let values = |model: &str| {
|
||||
let (_, o) = efforts.iter().find(|(m, _)| m == model).unwrap();
|
||||
let o = o.as_ref().unwrap();
|
||||
let values: Vec<_> = o["options"].as_array().unwrap().iter().map(|v| v["value"].clone()).collect();
|
||||
(values, o["currentValue"].clone())
|
||||
};
|
||||
assert_eq!(values("default"), (vec![json!("default"), json!("low"), json!("high")], json!("high")));
|
||||
// The configured effort only where the model offers it.
|
||||
assert_eq!(values("opus"), (vec![json!("default"), json!("low"), json!("max")], json!("default")));
|
||||
assert!(efforts.contains(&("claude-haiku-4-5".into(), None)), "no effort option without levels");
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,14 @@ pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
|
||||
builtins().into_iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// The ACP adapters of Claude Code and Codex, which the native drivers replace: they still
|
||||
/// launch, but are listed only once customized through an agent file or used by a task.
|
||||
const UNLISTED: &[&str] = &["claude-acp", "codex-acp"];
|
||||
|
||||
pub(crate) fn is_unlisted(spec: &ProviderSpec) -> bool {
|
||||
UNLISTED.contains(&spec.id.as_str()) && builtin_spec(&spec.id).as_ref() == Some(spec)
|
||||
}
|
||||
|
||||
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
|
||||
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
let builtin = builtin_spec(&spec.id);
|
||||
@@ -591,6 +599,15 @@ mod tests {
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
|
||||
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -2521,10 +2521,10 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
@@ -2533,6 +2533,31 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let effort = |values: &[&str], current: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
|
||||
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
|
||||
] }))
|
||||
};
|
||||
let model = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
|
||||
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
|
||||
] }));
|
||||
// A session already reported sonnet's levels; discovery adds opus's.
|
||||
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
|
||||
let levels =
|
||||
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
|
||||
mgr.shared.remember_discovered("claude", &model, &[], &levels);
|
||||
let known = mgr.shared.known.lock();
|
||||
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
|
||||
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_sent_as_appends() {
|
||||
let mut streamed = HashMap::new();
|
||||
@@ -2568,3 +2593,82 @@ fn streamed_text_is_sent_as_appends() {
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_keep_the_agents_details() {
|
||||
let details = json!({ "details": "spawn claude ENOENT" });
|
||||
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
|
||||
assert_eq!(
|
||||
with_details("Internal error".into(), Some(&json!("model not found"))),
|
||||
"Internal error: model not found"
|
||||
);
|
||||
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
|
||||
assert_eq!(with_details("Internal error".into(), None), "Internal error");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "{args:?}");
|
||||
};
|
||||
let write = |rel: &str, text: &str| std::fs::write(dir.join(rel), text).unwrap();
|
||||
let read = |rel: &str| std::fs::read_to_string(dir.join(rel)).unwrap();
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
|
||||
write("mine.txt", "base\n");
|
||||
write("other.txt", "base\n");
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-q", "-m", "init"]);
|
||||
// The user's own uncommitted edit, before the task starts.
|
||||
write("mine.txt", "my edit\n");
|
||||
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Hello"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// What the agent did.
|
||||
write("mine.txt", "my edit\nand the agent's\n");
|
||||
write("other.txt", "agent\n");
|
||||
write("new.txt", "agent\n");
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
|
||||
assert_eq!(read("other.txt"), "base\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
|
||||
|
||||
// After a restart, from the saved copy.
|
||||
mgr.shutdown();
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
|
||||
}
|
||||
@@ -116,7 +116,7 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
fn stamp(path: &Path) -> Stamp {
|
||||
pub(crate) fn stamp(path: &Path) -> Stamp {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
|
||||
Some((meta.len(), modified))
|
||||
@@ -132,6 +132,77 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
|
||||
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
|
||||
}
|
||||
|
||||
/// Files larger than this that were changed before the task keep no copy: they can't be rolled back.
|
||||
const SAVED_MAX: u64 = 8 * 1024 * 1024;
|
||||
|
||||
fn saved_name(rel: &str) -> String {
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
let mut hash = DefaultHasher::new();
|
||||
rel.hash(&mut hash);
|
||||
format!("{:016x}", hash.finish())
|
||||
}
|
||||
|
||||
/// Copies the files in `before` (already changed in the checkout at `cwd` when the task started)
|
||||
/// into `dir`, so rolling back the task's changes to them restores the user's own edits.
|
||||
/// Whether anything was saved.
|
||||
pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Path) -> IdeResult<bool> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(false) };
|
||||
let mut saved = false;
|
||||
for (rel, stamp) in before {
|
||||
let path = repo.root().join(rel);
|
||||
if stamp.is_none() || !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= SAVED_MAX) {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
std::fs::copy(&path, dir.join(saved_name(rel)))?;
|
||||
saved = true;
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Rolls `change` back: to the copy saved when the task started when the file had changes
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
|
||||
/// lacks it).
|
||||
pub(crate) fn restore(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
change: &AgentChange,
|
||||
before: &BTreeMap<String, Stamp>,
|
||||
saved: Option<&Path>,
|
||||
) -> IdeResult<()> {
|
||||
let path = &change.path.0;
|
||||
let content = match before.get(&change.rel_path) {
|
||||
Some(None) => None,
|
||||
Some(Some(_)) => {
|
||||
let copy =
|
||||
saved.map(|d| d.join(saved_name(&change.rel_path))).filter(|p| p.is_file()).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"{} had changes of yours when the task started that weren't saved; roll it back in Git instead",
|
||||
change.rel_path
|
||||
))
|
||||
})?;
|
||||
Some(std::fs::read(copy)?)
|
||||
}
|
||||
None => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(base))?
|
||||
}
|
||||
};
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)?;
|
||||
}
|
||||
None => match std::fs::remove_file(path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
|
||||
let encoding = match ide_vfs::load_text(path) {
|
||||
|
||||
@@ -263,7 +263,12 @@ pub(super) fn maven_steps(
|
||||
return Err(IdeError::not_found("no Maven project found"));
|
||||
}
|
||||
let step = |p: &maven::MavenProject, extra: &[String]| {
|
||||
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
|
||||
let mut s = BuildStep::new(
|
||||
&p.command,
|
||||
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
|
||||
&subdir(root, &p.dir),
|
||||
Format::Maven,
|
||||
);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
@@ -1044,9 +1049,21 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
"clean",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
|
||||
@@ -1320,12 +1337,36 @@ mod tests {
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
goal
|
||||
]
|
||||
);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
"clean",
|
||||
goal
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +528,17 @@ mod tests {
|
||||
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
|
||||
assert_eq!(step.cwd, root.join(name));
|
||||
assert_eq!(step.program, format!("{name}-mvnw"));
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod sources;
|
||||
|
||||
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
|
||||
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
|
||||
/// isn't declared in the build, such as a vendor jar downloaded through another project or
|
||||
/// installed by hand, and fails when no declared repository has it. Tracking under another
|
||||
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
|
||||
/// plain Maven's behaviour.
|
||||
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
||||
|
||||
@@ -341,7 +341,13 @@ pub fn sync_maven(
|
||||
// `-T1` keeps mvnd serial: its default parallel build interleaves the
|
||||
// `dependency:tree` output of concurrent modules, which the sync parses
|
||||
// as one stream. mvn is serial anyway, so this only pins the daemon.
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
|
||||
let mut args: Vec<String> = vec![
|
||||
"-B".into(),
|
||||
"-fae".into(),
|
||||
"-Dstyle.color=never".into(),
|
||||
"-T1".into(),
|
||||
maven::LOCAL_REPO_TRACKING_ARG.into(),
|
||||
];
|
||||
args.extend(sync_maven_options(&options.maven_options));
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
|
||||
@@ -73,6 +73,8 @@ pub struct EditorAppearanceSettings {
|
||||
pub whitespace_inner: bool,
|
||||
pub whitespace_trailing: bool,
|
||||
pub indent_guides: bool,
|
||||
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
|
||||
pub rainbow_indent_guides: bool,
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
@@ -95,6 +97,7 @@ impl Default for EditorAppearanceSettings {
|
||||
whitespace_inner: true,
|
||||
whitespace_trailing: true,
|
||||
indent_guides: true,
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
distraction_free_width: 80,
|
||||
|
||||
@@ -104,6 +104,11 @@ pub struct AgentSettings {
|
||||
#[serde(deserialize_with = "agent_defaults")]
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
|
||||
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
|
||||
/// MCP servers passed to coding agents in `session/new`, `session/load` and `session/fork`,
|
||||
/// keyed by server name (`[agents.mcpServers.<name>]`).
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = BTreeMap<String, AgentMcpServer>)]
|
||||
pub mcp_servers: BTreeMap<String, AgentMcpServer>,
|
||||
}
|
||||
|
||||
impl Default for AgentSettings {
|
||||
@@ -121,6 +126,58 @@ impl Default for AgentSettings {
|
||||
send_with: SendKey::Enter,
|
||||
while_running: WhileRunning::Queue,
|
||||
defaults: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport of an MCP server configured in Settings › Agents › MCP Servers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpTransport {
|
||||
#[default]
|
||||
Stdio,
|
||||
Http,
|
||||
Sse,
|
||||
}
|
||||
|
||||
/// An MCP server passed to coding agents in `session/new`, `session/load` and `session/fork`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentMcpServer {
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = McpTransport)]
|
||||
pub transport: McpTransport,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Agent provider ids this server is enabled for (e.g. `["claude", "codex"]`);
|
||||
/// empty means enabled for all agents.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub agents: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentMcpServer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transport: McpTransport::Stdio,
|
||||
enabled: true,
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
url: None,
|
||||
headers: BTreeMap::new(),
|
||||
agents: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1273,6 +1330,11 @@ pub struct ProjectStructureSettings {
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
|
||||
/// A member not listed here (or with an empty list) navigates only into itself.
|
||||
pub member_dependencies: BTreeMap<String, Vec<String>>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
@@ -1446,6 +1508,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
|
||||
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
|
||||
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
|
||||
// accepts a key path it finds there.
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
|
||||
for on in [true, false] {
|
||||
let mut settings = Settings::default();
|
||||
settings.vcs.commit.double_click_opens_diff = Some(on);
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
// Explicit `null` reads back as unset.
|
||||
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
|
||||
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
|
||||
@@ -43,6 +43,7 @@ impl Default for VcsSettings {
|
||||
pub struct GitSettings {
|
||||
/// Show blame after the current editor line.
|
||||
pub inline_blame: bool,
|
||||
pub show_branch_in_project_view: bool,
|
||||
/// Path to the git executable; empty = auto-detect.
|
||||
pub executable: String,
|
||||
/// Default of Update Project: `merge` | `rebase`.
|
||||
@@ -74,6 +75,7 @@ impl Default for GitSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inline_blame: false,
|
||||
show_branch_in_project_view: false,
|
||||
executable: String::new(),
|
||||
update_method: "merge".into(),
|
||||
auto_update_on_push_rejected: false,
|
||||
@@ -115,6 +117,13 @@ pub struct CommitSettings {
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// Double-click / Enter in the changes view: `None` follows the interface (the
|
||||
/// Commit tool window opens the diff, Local Changes the source file).
|
||||
///
|
||||
/// No `skip_serializing_if`: an unset value must still appear in
|
||||
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
|
||||
#[specta(optional)]
|
||||
pub double_click_opens_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -135,6 +144,7 @@ impl Default for CommitSettings {
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,13 +246,7 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
@@ -285,6 +279,25 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
|
||||
}
|
||||
}
|
||||
|
||||
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
|
||||
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
|
||||
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
|
||||
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
|
||||
/// output (UTF-8, else the system code page) before parsing.
|
||||
fn parse_message(body: &[u8]) -> Option<Value> {
|
||||
let error = match serde_json::from_slice(body) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
if std::str::from_utf8(body).is_err()
|
||||
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
tracing::warn!("dap: unparsable message: {error}");
|
||||
None
|
||||
}
|
||||
|
||||
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
|
||||
fn error_message(msg: &Value) -> String {
|
||||
if let Some(err) = msg.pointer("/body/error")
|
||||
@@ -306,6 +319,22 @@ fn error_message(msg: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_output_events_with_non_utf8_text() {
|
||||
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
|
||||
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
|
||||
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
|
||||
body.extend_from_slice(br#"\n"}}"#);
|
||||
let msg = parse_message(&body).expect("parsed");
|
||||
assert_eq!(msg["event"], "output");
|
||||
let output = msg["body"]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with('\n'));
|
||||
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
|
||||
#[cfg(not(windows))]
|
||||
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
|
||||
assert!(parse_message(b"{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_structured_errors() {
|
||||
let msg =
|
||||
|
||||
@@ -20,7 +20,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracle.workspace = true
|
||||
oracledb.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
@@ -1042,7 +1042,7 @@ pub enum Held {
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1058,7 +1058,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -92,12 +93,21 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
/// An Oracle package (its procedures and functions are not listed separately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPackageInfo")]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions), packages and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
@@ -296,7 +306,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
@@ -853,6 +863,14 @@ impl DbManager {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::packages(driver, schema) {
|
||||
out.packages = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
@@ -870,9 +888,29 @@ impl DbManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
|
||||
match pool.rows(&q, MAX_META_ROWS).await {
|
||||
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
|
||||
// A spec-only package has no body: the spec alone still opens.
|
||||
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
return Ok(defs
|
||||
.iter()
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
|
||||
+605
-243
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,7 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
|
||||
@@ -664,6 +664,32 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
|
||||
/// listed separately, as in IDEA).
|
||||
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading a package's specification (`body` false) or body as DDL.
|
||||
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
|
||||
if body { "PACKAGE_BODY" } else { "PACKAGE" },
|
||||
quote_literal(driver, name),
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
@@ -963,6 +989,7 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
@@ -984,6 +1011,17 @@ mod tests {
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1001,6 +1039,7 @@ mod tests {
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(packages(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -420,7 +420,13 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
|
||||
}
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
|
||||
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
|
||||
let pathext: Vec<String> = pathext.split(';').filter(|e| !e.is_empty()).map(str::to_owned).collect();
|
||||
// A name given with its extension (`cargo.exe`) is looked up as written.
|
||||
let has_ext = candidate.extension().is_some_and(|e| {
|
||||
pathext.iter().any(|x| x.trim_start_matches('.').eq_ignore_ascii_case(&e.to_string_lossy()))
|
||||
});
|
||||
if has_ext { vec![String::new()] } else { pathext }
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
|
||||
@@ -19,8 +19,10 @@ mod index;
|
||||
pub mod ledger;
|
||||
mod manifest;
|
||||
pub mod package;
|
||||
mod plugin_point;
|
||||
mod point;
|
||||
mod registry;
|
||||
mod resolve;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
@@ -30,7 +32,12 @@ pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use plugin_point::{FieldType, PointDecl};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use registry::{
|
||||
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
|
||||
PreparedSet,
|
||||
};
|
||||
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -26,6 +26,8 @@
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::resolve::Dependency;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -66,6 +68,10 @@ pub struct Manifest {
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `[[depends]]`: plugins this one needs (API 1.5).
|
||||
pub depends: Vec<Dependency>,
|
||||
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
|
||||
pub extension_points: Vec<PointDecl>,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
@@ -151,6 +157,7 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"extensions-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
@@ -210,6 +217,10 @@ struct Raw {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
depends: Vec<Dependency>,
|
||||
#[serde(default)]
|
||||
extension_points: Vec<PointDecl>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
@@ -281,6 +292,8 @@ impl Manifest {
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
depends: raw.depends,
|
||||
extension_points: raw.extension_points,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
@@ -357,6 +370,9 @@ impl Manifest {
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
|
||||
self.needs_api("`on-event:extensions-changed`", 5)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -367,6 +383,7 @@ impl Manifest {
|
||||
for c in &self.declared_categories {
|
||||
validate_item_id(c, "category")?;
|
||||
}
|
||||
self.validate_relations()?;
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
@@ -376,6 +393,42 @@ impl Manifest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[[depends]]` and `[[extension-points]]`.
|
||||
fn validate_relations(&self) -> Result<(), String> {
|
||||
if !self.depends.is_empty() {
|
||||
self.needs_api("`[[depends]]`", 5)?;
|
||||
}
|
||||
if !self.extension_points.is_empty() {
|
||||
self.needs_api("`[[extension-points]]`", 5)?;
|
||||
}
|
||||
let mut required = BTreeSet::new();
|
||||
for dep in &self.depends {
|
||||
validate_plugin_id(&dep.id)?;
|
||||
if dep.id == self.id {
|
||||
return Err(format!("`depends`: {} cannot depend on itself", self.id));
|
||||
}
|
||||
if let Some(req) = &dep.version
|
||||
&& semver::VersionReq::parse(req).is_err()
|
||||
{
|
||||
return Err(format!(
|
||||
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
|
||||
req, dep.id
|
||||
));
|
||||
}
|
||||
if !required.insert(&dep.id) {
|
||||
return Err(format!("`depends`: {} is listed twice", dep.id));
|
||||
}
|
||||
}
|
||||
let mut declared = BTreeSet::new();
|
||||
for point in &self.extension_points {
|
||||
point.validate(&self.id)?;
|
||||
if !declared.insert(&point.id) {
|
||||
return Err(format!("extension point {} is declared twice", point.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
@@ -472,11 +525,12 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.len() <= 128
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
@@ -711,6 +765,86 @@ mod api14_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod relations_tests {
|
||||
use super::*;
|
||||
|
||||
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
|
||||
|
||||
fn parse(rest: &str) -> Result<Manifest, String> {
|
||||
Manifest::parse(&format!("{HEAD}{rest}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_depends_and_extension_points() {
|
||||
let m = parse(
|
||||
r#"
|
||||
[[depends]]
|
||||
id = "acme.base"
|
||||
version = "^0.2"
|
||||
|
||||
[[depends]]
|
||||
id = "acme.extra"
|
||||
optional = true
|
||||
|
||||
[[extension-points]]
|
||||
id = "acme.app.hooks"
|
||||
required = ["name"]
|
||||
fields = { name = "string" }
|
||||
|
||||
[[contributes."acme.base.things"]]
|
||||
name = "x"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.depends.len(), 2);
|
||||
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
|
||||
assert!(m.depends[1].optional);
|
||||
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
|
||||
assert_eq!(m.declarations("acme.base.things").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_keys_need_api_1_5() {
|
||||
let older =
|
||||
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
|
||||
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let current = older.replace("^1.4", "^1.5");
|
||||
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_dependencies() {
|
||||
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
|
||||
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
|
||||
assert!(
|
||||
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
|
||||
);
|
||||
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("listed twice"));
|
||||
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_points_outside_the_plugins_namespace() {
|
||||
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
|
||||
assert!(err.contains("acme.app.<name>"), "{err}");
|
||||
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("declared twice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_ids_with_underscores_work_as_contribution_keys() {
|
||||
assert!(validate_point_id("my_tools.base.things").is_ok());
|
||||
assert!(validate_point_id("Bad").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Extension points a plugin declares for other plugins to contribute to
|
||||
//! (`[[extension-points]]`), and the check of a contribution against them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const MAX_FIELDS: usize = 32;
|
||||
const MAX_ID: usize = 128;
|
||||
/// Longest string a contribution may carry in one field.
|
||||
const MAX_TEXT: usize = 64 * 1024;
|
||||
const MAX_LIST: usize = 1000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FieldType {
|
||||
String,
|
||||
Boolean,
|
||||
Integer,
|
||||
Number,
|
||||
StringList,
|
||||
}
|
||||
|
||||
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PointDecl {
|
||||
/// `<plugin id>.<name>`.
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Fields a contribution must have.
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
/// Every field a contribution may have; a contribution with another key is refused.
|
||||
#[serde(default)]
|
||||
pub fields: BTreeMap<String, FieldType>,
|
||||
}
|
||||
|
||||
impl PointDecl {
|
||||
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
|
||||
let prefix = format!("{plugin_id}.");
|
||||
let name = self.id.strip_prefix(&prefix).unwrap_or("");
|
||||
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if !name_ok || self.id.len() > MAX_ID {
|
||||
return Err(format!(
|
||||
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
|
||||
self.id
|
||||
));
|
||||
}
|
||||
if self.description.len() > 500 {
|
||||
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
|
||||
}
|
||||
if self.fields.len() > MAX_FIELDS {
|
||||
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
|
||||
}
|
||||
for field in self.fields.keys() {
|
||||
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
&& field.len() <= 64;
|
||||
if !ok {
|
||||
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
|
||||
}
|
||||
}
|
||||
for field in &self.required {
|
||||
if !self.fields.contains_key(field) {
|
||||
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
|
||||
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
let toml::Value::Table(table) = value else {
|
||||
return Err("a contribution is a table".into());
|
||||
};
|
||||
for key in table.keys() {
|
||||
if !self.fields.contains_key(key) {
|
||||
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
|
||||
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
|
||||
}
|
||||
}
|
||||
for key in &self.required {
|
||||
if !table.contains_key(key) {
|
||||
return Err(format!("missing required field {key:?}"));
|
||||
}
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, value) in table {
|
||||
out.insert(key.clone(), convert(key, self.fields[key], value)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
use serde_json::Value as J;
|
||||
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
|
||||
match (ty, value) {
|
||||
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
|
||||
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
|
||||
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
|
||||
(FieldType::StringList, toml::Value::Array(items)) => {
|
||||
if items.len() > MAX_LIST {
|
||||
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
toml::Value::String(s) => text(key, s).map(J::from),
|
||||
_ => Err(wrong("a list of strings")),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(J::Array)
|
||||
}
|
||||
(FieldType::String, _) => Err(wrong("a string")),
|
||||
(FieldType::Boolean, _) => Err(wrong("true or false")),
|
||||
(FieldType::Integer, _) => Err(wrong("an integer")),
|
||||
(FieldType::Number, _) => Err(wrong("a number")),
|
||||
(FieldType::StringList, _) => Err(wrong("a list of strings")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(key: &str, s: &str) -> Result<String, String> {
|
||||
if s.len() > MAX_TEXT {
|
||||
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
|
||||
}
|
||||
Ok(s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decl(src: &str) -> PointDecl {
|
||||
toml::from_str(src).unwrap()
|
||||
}
|
||||
|
||||
const COMPLETIONS: &str = r#"
|
||||
id = "acme.base.completions"
|
||||
description = "Extra completions"
|
||||
required = ["label"]
|
||||
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn the_id_is_namespaced_by_the_declaring_plugin() {
|
||||
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
|
||||
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
|
||||
assert!(err.contains("acme.other.<name>"), "{err}");
|
||||
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
|
||||
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_must_be_declared() {
|
||||
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
|
||||
assert!(err.contains("required field \"a\""), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_a_contribution_and_converts_it_to_json() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let contribution: toml::Value =
|
||||
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
|
||||
let json = point.check(&contribution).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_the_point_does_not_declare() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
|
||||
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
|
||||
let err = check("label = \"a\"\nextra = 1");
|
||||
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
|
||||
assert!(check("label = 1").contains("must be a string"));
|
||||
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
|
||||
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_take_integers_and_floats() {
|
||||
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension points plus the contributions committed to them, accounted per plugin.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -23,7 +24,7 @@ pub struct ContributionInfo {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: &'static str,
|
||||
point: String,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
@@ -38,7 +39,7 @@ pub struct PreparedSet {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
items: Vec<(String, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
@@ -61,11 +62,41 @@ impl std::fmt::Debug for PreparedSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one plugin contributed to an extension point another plugin declared.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Contribution {
|
||||
/// The contributing plugin.
|
||||
pub plugin: String,
|
||||
/// The declaration as JSON, checked against the point's fields.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// An extension point a plugin declared, with who contributes to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeclaredPoint {
|
||||
pub owner: String,
|
||||
pub decl: PointDecl,
|
||||
/// Plugin ids, in commit order, once each.
|
||||
pub contributors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Dynamic {
|
||||
points: BTreeMap<String, (String, PointDecl)>,
|
||||
contributions: BTreeMap<String, Vec<Contribution>>,
|
||||
}
|
||||
|
||||
/// Called with (point id, owner plugin) after contributions to a declared point changed.
|
||||
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionRegistry {
|
||||
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
|
||||
/// plugin id → its contributions in commit order.
|
||||
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
|
||||
/// Points declared by plugins and what was contributed to them.
|
||||
dynamic: Arc<Mutex<Dynamic>>,
|
||||
listener: Arc<RwLock<Option<ChangeListener>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
@@ -97,6 +128,111 @@ impl ExtensionRegistry {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Registers the function told when contributions to a plugin-declared point change.
|
||||
pub fn set_change_listener(&self, listener: ChangeListener) {
|
||||
*self.listener.write() = Some(listener);
|
||||
}
|
||||
|
||||
/// The plugin that declared `point`, while it is committed.
|
||||
pub fn point_owner(&self, point: &str) -> Option<String> {
|
||||
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
|
||||
}
|
||||
|
||||
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
|
||||
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
if !dynamic.points.contains_key(point) {
|
||||
return Vec::new();
|
||||
}
|
||||
dynamic.contributions.get(point).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The points `owner` declared, with their contributors.
|
||||
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
dynamic
|
||||
.points
|
||||
.iter()
|
||||
.filter(|(_, (o, _))| o == owner)
|
||||
.map(|(id, (owner, decl))| {
|
||||
let mut contributors: Vec<String> = Vec::new();
|
||||
for c in dynamic.contributions.get(id).into_iter().flatten() {
|
||||
if !contributors.contains(&c.plugin) {
|
||||
contributors.push(c.plugin.clone());
|
||||
}
|
||||
}
|
||||
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
|
||||
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
|
||||
pkg.manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|decl| {
|
||||
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
|
||||
let id = decl.id.clone();
|
||||
let prepared = Prepared::new(format!("Extension point {id}"), move || {
|
||||
let id = decl.id.clone();
|
||||
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
|
||||
Ok(Disposable::new(move || {
|
||||
dynamic.lock().points.remove(&id);
|
||||
}))
|
||||
})
|
||||
.key(format!("extension-point:{id}"));
|
||||
(id, prepared)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
|
||||
/// when no plugin declares `point` (the caller then skips it as unknown).
|
||||
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
|
||||
let declared = self.dynamic.lock().points.get(point).cloned();
|
||||
let own =
|
||||
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
|
||||
let (owner, decl) = declared.or(own)?;
|
||||
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
|
||||
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
|
||||
}
|
||||
let listener = self.listener.clone();
|
||||
let result = pkg
|
||||
.manifest
|
||||
.declarations(point)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
|
||||
let label = decl
|
||||
.required
|
||||
.first()
|
||||
.and_then(|field| data.get(field))
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or_else(|| point.to_owned(), str::to_owned);
|
||||
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
|
||||
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
|
||||
Ok(Prepared::new(label, move || {
|
||||
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
|
||||
notify(&dynamic, &listener, &point);
|
||||
Ok(Disposable::new(move || {
|
||||
{
|
||||
let mut guard = dynamic.lock();
|
||||
if let Some(list) = guard.contributions.get_mut(&point)
|
||||
&& let Some(pos) = list.iter().position(|c| *c == contribution)
|
||||
{
|
||||
list.remove(pos);
|
||||
}
|
||||
}
|
||||
notify(&dynamic, &listener, &point);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
|
||||
/// whole on the first invalid declaration; unknown points are skipped with
|
||||
/// a warning (forward compatibility).
|
||||
@@ -108,9 +244,24 @@ impl ExtensionRegistry {
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
// Declared points first: contributions to them (the plugin's own too) commit after.
|
||||
set.items.extend(self.prepare_declarations(pkg));
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
|
||||
if let Some(result) = self.prepare_contributions(pkg, point_id) {
|
||||
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
|
||||
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
|
||||
continue;
|
||||
}
|
||||
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
|
||||
let owner =
|
||||
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
|
||||
let msg = match owner {
|
||||
Some(owner) => {
|
||||
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
|
||||
}
|
||||
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
|
||||
};
|
||||
tracing::warn!(plugin = %pkg.id(), "{msg}");
|
||||
set.warnings.push(msg);
|
||||
continue;
|
||||
@@ -129,7 +280,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
}
|
||||
}
|
||||
if let Some(point) = self.point(LOCALES_POINT)
|
||||
@@ -144,7 +295,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
@@ -201,7 +352,7 @@ impl ExtensionRegistry {
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -217,6 +368,14 @@ impl ExtensionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
|
||||
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
|
||||
let callback = listener.read().clone();
|
||||
if let (Some(owner), Some(callback)) = (owner, callback) {
|
||||
callback(point, &owner);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
@@ -310,6 +469,122 @@ mod tests {
|
||||
assert!(reg.contributions("p").is_empty());
|
||||
}
|
||||
|
||||
fn pkg_of(id: &str, body: &str) -> Package {
|
||||
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
[[extension-points]]
|
||||
id = "acme.base.things"
|
||||
required = ["name"]
|
||||
fields = { name = "string", weight = "integer" }
|
||||
"#;
|
||||
const PLUGIN: &str =
|
||||
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
|
||||
|
||||
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
|
||||
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
let got = reg.contributions_to("acme.base.things");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].plugin, "acme.more");
|
||||
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
|
||||
assert_eq!(
|
||||
reg.contributions("acme.more")[0],
|
||||
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
|
||||
);
|
||||
let declared = reg.declared_points("acme.base");
|
||||
assert_eq!(declared.len(), 1);
|
||||
assert_eq!(declared[0].contributors, ["acme.more"]);
|
||||
|
||||
assert!(reg.dispose_plugin("acme.more"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
assert!(reg.dispose_plugin("acme.base"));
|
||||
assert_eq!(reg.point_owner("acme.base.things"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributions_disappear_from_view_while_the_owner_is_gone() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.base");
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributing_needs_a_dependency_on_the_owner() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let err =
|
||||
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
|
||||
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contribution_is_checked_against_the_declared_fields() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
|
||||
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
|
||||
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_nobody_declares_is_skipped_with_a_warning() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
assert!(set.is_empty());
|
||||
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
let warning = &set.warnings[0];
|
||||
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
|
||||
assert!(!warning.contains("this version of the IDE"), "{warning}");
|
||||
|
||||
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
|
||||
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
|
||||
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_may_contribute_to_its_own_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
|
||||
commit(®, &pkg_of("acme.base", &own));
|
||||
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_hears_about_changes_to_its_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
|
||||
let sink = heard.clone();
|
||||
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert!(heard.lock().is_empty(), "declaring is not a contribution");
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.more");
|
||||
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
|
||||
assert_eq!(*heard.lock(), [expected.clone(), expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
|
||||
let (reg, _) = setup();
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Which plugins can run, and in which order, given what they depend on.
|
||||
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
/// `[[depends]]` of a manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
pub id: String,
|
||||
/// Semver requirement on the dependency's version; none accepts any.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// The plugin runs without it; when present it starts first.
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
impl Dependency {
|
||||
/// `acme.base ^1.0`.
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.version {
|
||||
Some(v) => format!("{} {v}", self.id),
|
||||
None => self.id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin as the resolver sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Node<'a> {
|
||||
pub id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub depends: &'a [Dependency],
|
||||
/// Not switched off by the user.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Why an enabled plugin cannot run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum Blocked {
|
||||
/// A required plugin is not installed.
|
||||
Missing { requirement: String },
|
||||
/// A required plugin is switched off.
|
||||
Disabled { id: String },
|
||||
/// A required plugin is installed in a version that does not satisfy the requirement.
|
||||
Version { requirement: String, found: String },
|
||||
/// A required plugin cannot run itself.
|
||||
Dependency { id: String },
|
||||
/// The plugin is part of a dependency cycle (`a → b → a`).
|
||||
Cycle { ring: Vec<String> },
|
||||
}
|
||||
|
||||
impl fmt::Display for Blocked {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
|
||||
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
|
||||
Self::Version { requirement, found } => {
|
||||
write!(f, "Requires {requirement}, but version {found} is installed")
|
||||
}
|
||||
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
|
||||
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
/// Plugins that can run, every plugin after the plugins it depends on.
|
||||
pub order: Vec<String>,
|
||||
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
|
||||
pub blocked: BTreeMap<String, Blocked>,
|
||||
}
|
||||
|
||||
/// Decides, for every enabled plugin, whether its required plugins allow it to
|
||||
/// run and the order to start them in (the counterpart of IntelliJ's
|
||||
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
|
||||
pub fn plan(nodes: &[Node<'_>]) -> Plan {
|
||||
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
|
||||
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
|
||||
|
||||
for node in &enabled {
|
||||
for dep in node.depends.iter().filter(|d| !d.optional) {
|
||||
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
|
||||
blocked.entry(node.id.to_owned()).or_insert(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles among plugins whose requirements are otherwise present.
|
||||
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
let graph: Graph<'_> = candidates
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
|
||||
(*id, deps.filter(|d| candidates.contains(d)).collect())
|
||||
})
|
||||
.collect();
|
||||
for component in components(&graph) {
|
||||
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
|
||||
if cyclic {
|
||||
let ring = ring_of(&component, &graph);
|
||||
for id in component {
|
||||
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that needs a blocked plugin is blocked too.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for node in &enabled {
|
||||
if blocked.contains_key(node.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
|
||||
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
Plan { order: topological(&runnable, &by_id), blocked }
|
||||
}
|
||||
|
||||
/// Plugins that need `id`, directly or through other plugins, whether or not
|
||||
/// they are enabled. Optional dependencies do not count.
|
||||
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
|
||||
let mut found: BTreeSet<String> = BTreeSet::new();
|
||||
let mut frontier = vec![id.to_owned()];
|
||||
while let Some(current) = frontier.pop() {
|
||||
for node in nodes {
|
||||
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
|
||||
frontier.push(node.id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
found.remove(id);
|
||||
found.into_iter().collect()
|
||||
}
|
||||
|
||||
/// How one dependency stands right now, for the plugin details page.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyState {
|
||||
Satisfied,
|
||||
Missing,
|
||||
Disabled,
|
||||
/// Installed, wrong version.
|
||||
Version,
|
||||
/// Installed and enabled, but cannot run itself.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
|
||||
match direct_reason(dep, found) {
|
||||
Some(Blocked::Missing { .. }) => DependencyState::Missing,
|
||||
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
|
||||
Some(Blocked::Version { .. }) => DependencyState::Version,
|
||||
_ if found_blocked => DependencyState::Blocked,
|
||||
_ => DependencyState::Satisfied,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
|
||||
let Some(target) = found else {
|
||||
return Some(Blocked::Missing { requirement: dep.describe() });
|
||||
};
|
||||
if !target.enabled {
|
||||
return Some(Blocked::Disabled { id: dep.id.clone() });
|
||||
}
|
||||
let want = dep.version.as_deref()?;
|
||||
let satisfied = VersionReq::parse(want)
|
||||
.ok()
|
||||
.zip(Version::parse(target.version).ok())
|
||||
.is_some_and(|(req, have)| req.matches(&have));
|
||||
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
|
||||
}
|
||||
|
||||
/// Plugin id → the plugin ids it requires.
|
||||
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
|
||||
|
||||
/// Strongly connected components (Tarjan) of `graph`.
|
||||
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
|
||||
struct State<'a, 'g> {
|
||||
index: usize,
|
||||
stack: Vec<&'a str>,
|
||||
info: BTreeMap<&'a str, (usize, usize, bool)>,
|
||||
out: Vec<Vec<&'a str>>,
|
||||
graph: &'g Graph<'a>,
|
||||
}
|
||||
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
|
||||
s.info.insert(v, (s.index, s.index, true));
|
||||
s.index += 1;
|
||||
s.stack.push(v);
|
||||
for &w in &s.graph[v] {
|
||||
match s.info.get(w).copied() {
|
||||
None => {
|
||||
visit(s, w);
|
||||
let low = s.info[w].1;
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(low);
|
||||
}
|
||||
Some((index, _, true)) => {
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(index);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let (index, low, _) = s.info[v];
|
||||
if index == low {
|
||||
let mut component = Vec::new();
|
||||
while let Some(w) = s.stack.pop() {
|
||||
s.info.get_mut(w).expect("visited").2 = false;
|
||||
component.push(w);
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
component.sort_unstable();
|
||||
s.out.push(component);
|
||||
}
|
||||
}
|
||||
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
|
||||
for &id in graph.keys() {
|
||||
if !state.info.contains_key(id) {
|
||||
visit(&mut state, id);
|
||||
}
|
||||
}
|
||||
state.out
|
||||
}
|
||||
|
||||
/// One cycle through `component`, from its smallest id back to it.
|
||||
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
|
||||
let start = component[0];
|
||||
let mut ring = vec![start.to_owned()];
|
||||
let mut current = start;
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
|
||||
loop {
|
||||
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
|
||||
next.sort_unstable();
|
||||
// Prefer closing the ring; otherwise take the smallest unseen member.
|
||||
let step =
|
||||
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
|
||||
ring.push(step.to_owned());
|
||||
if step == start {
|
||||
return ring;
|
||||
}
|
||||
seen.insert(step);
|
||||
current = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
|
||||
/// dependencies order too, but never block: leftovers (an optional edge
|
||||
/// closing a loop) are appended by id.
|
||||
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
|
||||
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
|
||||
(*id, deps.collect())
|
||||
})
|
||||
.collect();
|
||||
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
|
||||
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
|
||||
waiting.remove(next);
|
||||
for deps in waiting.values_mut() {
|
||||
deps.remove(next);
|
||||
}
|
||||
order.push(next.to_owned());
|
||||
}
|
||||
order.extend(waiting.keys().map(|id| (*id).to_owned()));
|
||||
order
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: false }
|
||||
}
|
||||
|
||||
fn dep_v(id: &str, version: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: Some(version.into()), optional: false }
|
||||
}
|
||||
|
||||
fn optional(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: true }
|
||||
}
|
||||
|
||||
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
|
||||
Node { id, version: "1.0.0", depends, enabled: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_start_first_whatever_the_ids_sort_like() {
|
||||
let (a, z) = ([dep("z.base")], []);
|
||||
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
|
||||
assert_eq!(plan.order, ["z.base", "a.app"]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_plugins_keep_id_order() {
|
||||
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
|
||||
assert_eq!(plan.order, ["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_requirement_blocks_with_the_requirement_text() {
|
||||
let needs = [dep_v("acme.base", "^1.0")];
|
||||
let plan = plan(&[node("acme.app", &needs)]);
|
||||
assert!(plan.order.is_empty());
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
|
||||
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
|
||||
let needs = [dep("acme.base")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.enabled = false;
|
||||
let plan = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
|
||||
assert!(!plan.blocked.contains_key("acme.base"));
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_installed_version_must_satisfy_the_requirement() {
|
||||
let needs = [dep_v("acme.base", "^0.2")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.version = "0.3.0";
|
||||
let plan_bad = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(
|
||||
plan_bad.blocked["acme.app"],
|
||||
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
|
||||
);
|
||||
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
|
||||
|
||||
base.version = "0.2.7";
|
||||
let plan_ok = plan(&[base, node("acme.app", &needs)]);
|
||||
assert!(plan_ok.blocked.is_empty());
|
||||
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_propagates_to_everything_downstream() {
|
||||
let (b, c) = ([dep("a")], [dep("b")]);
|
||||
let plan = plan(&[node("b", &b), node("c", &c)]);
|
||||
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
|
||||
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_requirement_never_blocks_but_orders_when_present() {
|
||||
let app = [optional("acme.extra")];
|
||||
let alone = plan(&[node("acme.app", &app)]);
|
||||
assert_eq!(alone.order, ["acme.app"]);
|
||||
assert!(alone.blocked.is_empty());
|
||||
|
||||
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
|
||||
assert_eq!(with.order, ["acme.extra", "acme.app"]);
|
||||
|
||||
let mut off = node("acme.extra", &[]);
|
||||
off.enabled = false;
|
||||
let disabled = plan(&[node("acme.app", &app), off]);
|
||||
assert_eq!(disabled.order, ["acme.app"]);
|
||||
assert!(disabled.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_blocks_its_members_and_names_the_ring() {
|
||||
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
|
||||
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
|
||||
assert_eq!(plan.blocked["a"], ring);
|
||||
assert_eq!(plan.blocked["b"], ring);
|
||||
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
|
||||
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_requiring_itself_is_a_cycle() {
|
||||
let a = [dep("a")];
|
||||
let plan = plan(&[node("a", &a)]);
|
||||
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
|
||||
let (a, b) = ([optional("b")], [optional("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b)]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
assert_eq!(plan.order.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependents_are_transitive_and_ignore_optional_edges() {
|
||||
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
|
||||
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
|
||||
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
|
||||
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_state_tells_the_details_page_what_to_say() {
|
||||
let want = dep_v("base", "^1.0");
|
||||
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
|
||||
let mut base = node("base", &[]);
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
|
||||
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
|
||||
base.version = "2.0.0";
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
|
||||
base.enabled = false;
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
NewAgentTask,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -277,6 +277,105 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
}
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
let user = host.global_settings.effective().agents.mcp_servers;
|
||||
let project = host.project(window).ok().filter(|p| !p.light);
|
||||
let workspace = project
|
||||
.as_ref()
|
||||
.map(|p| p.settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers)
|
||||
.unwrap_or_default();
|
||||
(user, workspace, project)
|
||||
}
|
||||
|
||||
fn persist_mcp_map(
|
||||
host: &Host,
|
||||
project: Option<&std::sync::Arc<crate::Project>>,
|
||||
scope: McpScope,
|
||||
map: &McpServerMap,
|
||||
) -> IdeResult<()> {
|
||||
let value = if map.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
};
|
||||
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
|
||||
match scope {
|
||||
McpScope::User => {
|
||||
host.global_settings.set(ide_workspace::settings::SettingsScope::Global, "agents.mcpServers", value)?;
|
||||
for p in host.projects() {
|
||||
p.settings.reload();
|
||||
p.sink.emit(event.clone());
|
||||
}
|
||||
}
|
||||
McpScope::Workspace => {
|
||||
let p = project.ok_or_else(|| IdeError::invalid("Open a project to configure workspace MCP servers"))?;
|
||||
p.settings.set(ide_workspace::settings::SettingsScope::Project, "agents.mcpServers", value)?;
|
||||
p.sink.emit(event);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
|
||||
where
|
||||
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
|
||||
{
|
||||
let (mut user, mut workspace, project) = mcp_maps(host, window);
|
||||
let prev_user = user.clone();
|
||||
let prev_workspace = workspace.clone();
|
||||
let project_root = project.as_ref().map(|p| p.root.as_path());
|
||||
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
|
||||
if workspace != prev_workspace {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
|
||||
}
|
||||
if user != prev_user {
|
||||
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
|
||||
}
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
|
||||
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
let (user, workspace, _) = mcp_maps(host, window);
|
||||
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
|
||||
}
|
||||
|
||||
/// Creates or updates a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, project_root| {
|
||||
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables or disables an MCP server and returns the updated list.
|
||||
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, _| {
|
||||
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a User or Workspace MCP server and returns the updated list.
|
||||
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
|
||||
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
update_mcp_maps(host, window, |_, workspace, project_root| {
|
||||
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
|
||||
ide_agent::mcp::apply_import(workspace, root);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
@@ -288,6 +387,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("agent_registry_remove", |c| async move { registry_remove(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("agent_mcp_list", |c| async move { mcp_list(&c.host, &c.window) });
|
||||
d.add("agent_mcp_save", |c| async move { mcp_save(&c.host, &c.window, c.arg("input")?) });
|
||||
d.add(
|
||||
"agent_mcp_set_enabled",
|
||||
|c| async move { mcp_set_enabled(&c.host, &c.window, c.arg("id")?, c.arg("enabled")?) },
|
||||
);
|
||||
d.add("agent_mcp_remove", |c| async move { mcp_remove(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("agent_mcp_import", |c| async move { mcp_import(&c.host, &c.window) });
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
@@ -321,6 +428,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Repository version lookups for manifest inspections and completion.
|
||||
use crate::{Host, rpc::Dispatcher};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, npm};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -25,6 +25,31 @@ impl DependencyVersionSource for CratesIo {
|
||||
}
|
||||
}
|
||||
|
||||
struct NpmRegistry;
|
||||
impl DependencyVersionSource for NpmRegistry {
|
||||
fn id(&self) -> &'static str {
|
||||
"npm"
|
||||
}
|
||||
|
||||
/// Scoped names (`@scope/name`) aren't file-name safe.
|
||||
fn cache_key(&self, package: &str) -> Option<String> {
|
||||
let mut hash = DefaultHasher::new();
|
||||
package.hash(&mut hash);
|
||||
Some(format!("{}-{:016x}", self.id(), hash.finish()))
|
||||
}
|
||||
|
||||
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let path = npm::registry_path(package).ok_or_else(|| IdeError::internal("invalid package name"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
// The abbreviated metadata: the full document of a large package runs to tens of megabytes.
|
||||
let text = ide_tools::Http::new(&search_path)?
|
||||
.https_only()
|
||||
.accept("application/vnd.npm.install-v1+json")
|
||||
.get_text_limited(&format!("https://registry.npmjs.org/{path}"), Some(32 * 1024 * 1024), cancel)?;
|
||||
npm::registry_versions(&text).ok_or_else(|| IdeError::internal("invalid registry metadata"))
|
||||
}
|
||||
}
|
||||
|
||||
struct GoModules {
|
||||
root: PathBuf,
|
||||
env: BTreeMap<String, String>,
|
||||
@@ -122,6 +147,13 @@ pub async fn dependency_versions(
|
||||
let versions = cache.versions(&source, &dependency.package, &cancel)?;
|
||||
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
|
||||
}
|
||||
if snap.path.file_name().is_some_and(|name| name == "package.json") {
|
||||
let Some(dependency) = offset.and_then(|offset| npm::dependency_at(&snap.text, offset)) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let versions = cache.versions(&NpmRegistry, &dependency.package, &cancel)?;
|
||||
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
|
||||
}
|
||||
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -208,4 +240,51 @@ mod tests {
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
struct CachedNpm;
|
||||
impl DependencyVersionSource for CachedNpm {
|
||||
fn id(&self) -> &'static str {
|
||||
"npm"
|
||||
}
|
||||
fn cache_key(&self, package: &str) -> Option<String> {
|
||||
NpmRegistry.cache_key(package)
|
||||
}
|
||||
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
Ok(vec!["20.11.0".into(), "18.19.3".into()])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn package_json_completes_the_version_at_the_cursor() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let text = "{\n \"devDependencies\": { \"@types/node\": \"^20\" }\n}\n";
|
||||
let path = root.join("package.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
let info = project.documents.open(&path).unwrap();
|
||||
host.dependency_versions.versions(&CachedNpm, "@types/node", &CancellationToken::new()).unwrap();
|
||||
let offset = text.find("^20").unwrap() as u32 + 2;
|
||||
let found = dependency_versions(&host, "main", info.id, Some(offset), RequestId(420)).await.unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].dependency.package, "@types/node");
|
||||
assert_eq!(found[0].versions, ["20.11.0", "18.19.3"]);
|
||||
// Inspections (no offset) and positions outside a version ask nothing.
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(421)).await.unwrap().is_empty());
|
||||
assert!(dependency_versions(&host, "main", info.id, Some(2), RequestId(422)).await.unwrap().is_empty());
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1443,28 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restarts the language servers named `name` in every open project, so a tool installed or
|
||||
/// updated from one window is what all of them run (not only that window's project).
|
||||
pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
|
||||
for project in host.projects() {
|
||||
let ids: Vec<ServerId> = project.lsp.servers().into_iter().filter(|s| s.name == name).map(|s| s.id).collect();
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for id in ids {
|
||||
if let Err(e) = project.lsp.restart(id).await {
|
||||
tracing::warn!(server = name, root = %project.root.display(), "restart after a tool update: {e}");
|
||||
}
|
||||
}
|
||||
if name == "jdtls"
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
{
|
||||
bridge.schedule();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
@@ -112,6 +112,68 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
|
||||
/// in the project or one of its content roots (an attached dependency project included);
|
||||
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
|
||||
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
|
||||
/// `from a.b import x` needs this.
|
||||
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let dots = name.len() - name.trim_start_matches('.').len();
|
||||
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if parts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rel = parts.iter().collect::<PathBuf>();
|
||||
let bases: Vec<PathBuf> = if dots > 0 {
|
||||
let dir = PathBuf::from(from);
|
||||
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
|
||||
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
|
||||
vec![base]
|
||||
} else {
|
||||
crate::project::navigation_roots(&project, &from)
|
||||
};
|
||||
for base in bases {
|
||||
let dir = base.join(&rel);
|
||||
for candidate in
|
||||
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
|
||||
{
|
||||
if candidate.is_file() {
|
||||
return Ok(Some(IpcPath::from(candidate)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
|
||||
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
|
||||
/// the language server cannot resolve an import into an attached dependency project. `from`'s
|
||||
/// own root is left out: the server sees it, so a name it could not resolve there is not
|
||||
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
|
||||
pub async fn nav_index_definitions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
from: IpcPath,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let roots = crate::project::navigation_roots(&project, &from);
|
||||
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
|
||||
if roots.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Filter inside the index: a cap applied before the root filter can fill with
|
||||
// own-root matches and evict the dependency roots entirely.
|
||||
Ok(project.index.symbols_named_within(&name, None, roots, 500))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
@@ -181,6 +243,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("nav_resolve_module", |c| async move {
|
||||
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("nav_index_definitions", |c| async move {
|
||||
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
|
||||
@@ -221,7 +221,10 @@ pub fn set_settings(
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
|| key.starts_with("inspections.")
|
||||
|| key == "files"
|
||||
|| key == "files.composeFileOverrides"
|
||||
|| key.starts_with("files.composeFileOverrides.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -337,7 +337,7 @@ async fn start_inner(
|
||||
// what makes the setting take effect without reopening the project.
|
||||
if config.kind == "maven" {
|
||||
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
|
||||
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
|
||||
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
|
||||
@@ -130,6 +130,17 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// Variables of the `.env` files a debug launch names, in order (later files win):
|
||||
/// the same files a run loads, over the system environment debug adapters inherit.
|
||||
pub fn run_env_files(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
files: Vec<std::path::PathBuf>,
|
||||
) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
ide_run::envfile::load(&files, &ide_run::system_env())
|
||||
.map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
|
||||
}
|
||||
|
||||
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
|
||||
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
|
||||
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
|
||||
@@ -332,6 +343,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
|
||||
});
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_env_files", |c| async move { run_env_files(&c.host, &c.window, c.arg("files")?) });
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
|
||||
@@ -25,7 +25,12 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path) -> IdeResult<()> {
|
||||
/// Refuses (with `refusal`) to remove or move the worktree that holds the open project.
|
||||
fn check_worktree_away_from_project(
|
||||
project_root: &std::path::Path,
|
||||
path: &std::path::Path,
|
||||
refusal: &str,
|
||||
) -> IdeResult<()> {
|
||||
fn canonical_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
|
||||
let mut existing = path.to_path_buf();
|
||||
let mut tail = Vec::new();
|
||||
@@ -54,7 +59,7 @@ fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path
|
||||
}
|
||||
|
||||
if canonical_path(project_root)?.starts_with(canonical_path(path)?) {
|
||||
return Err(ide_core::IdeError::invalid("Cannot remove a worktree containing the open project."));
|
||||
return Err(ide_core::IdeError::invalid(refusal));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1058,7 +1058,11 @@ pub async fn vcs_remove_worktree(
|
||||
} else {
|
||||
project.with_repo_at(repo.as_deref(), |r| Ok(r.root().join(&path)))?
|
||||
};
|
||||
check_worktree_removal(&project.root, &path)?;
|
||||
check_worktree_away_from_project(
|
||||
&project.root,
|
||||
&path,
|
||||
"Cannot remove a worktree containing the open project.",
|
||||
)?;
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
@@ -1066,6 +1070,25 @@ pub async fn vcs_remove_worktree(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Move Worktree…: moves the linked worktree at `path` to `to`.
|
||||
pub async fn vcs_move_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
to: PathBuf,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
check_worktree_away_from_project(&project.root, &path, "Cannot move a worktree containing the open project.")?;
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.move_worktree(&path, &to));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.repos().compare_scope(&paths))).await
|
||||
@@ -1487,6 +1510,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_move_worktree", |c| async move {
|
||||
vcs_move_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_remove_worktree", |c| async move {
|
||||
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
|
||||
});
|
||||
|
||||
@@ -530,6 +530,8 @@ pub(super) struct Shared {
|
||||
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// Extension points plugins declare and what was contributed to them.
|
||||
pub registry: Arc<ide_extension::ExtensionRegistry>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
/// (plugin, window) → the log tabs its code opened there, oldest first:
|
||||
@@ -551,9 +553,11 @@ impl Shared {
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
ui: Arc<ide_extension::UiStore>,
|
||||
ledger: Arc<Mutex<Ledger>>,
|
||||
registry: Arc<ide_extension::ExtensionRegistry>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
registry,
|
||||
locale: RwLock::new("en".into()),
|
||||
sink,
|
||||
workspace: RwLock::new(None),
|
||||
@@ -1332,6 +1336,21 @@ impl HostApi for PluginApi {
|
||||
host.mcp_executable.read().as_ref().map(|exe| ide_mcp::stdio_command(exe, &root))
|
||||
}
|
||||
|
||||
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
|
||||
self.require_api("extension points", 5)?;
|
||||
match self.shared.registry.point_owner(point) {
|
||||
None => Err(HostError::NotFound(format!("no running plugin declares {point}"))),
|
||||
Some(owner) if owner != self.id() => Err(HostError::Denied(format!("extension point {point}"))),
|
||||
Some(_) => Ok(self
|
||||
.shared
|
||||
.registry
|
||||
.contributions_to(point)
|
||||
.into_iter()
|
||||
.map(|c| (c.plugin, c.data.to_string()))
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_run_target(&self, root: &Path, target: ide_plugin::PluginRunTarget) -> Result<(), HostError> {
|
||||
self.register_target(root, target)
|
||||
}
|
||||
@@ -3217,7 +3236,14 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
|
||||
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
|
||||
let shared = Shared::new(
|
||||
sink,
|
||||
dir.join(".data"),
|
||||
pins,
|
||||
ide_extension::UiStore::new(),
|
||||
Arc::default(),
|
||||
Arc::new(ide_extension::ExtensionRegistry::new()),
|
||||
);
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
@@ -3952,6 +3978,7 @@ mod workbench_tests {
|
||||
Arc::default(),
|
||||
ide_extension::UiStore::new(),
|
||||
Arc::default(),
|
||||
Arc::new(ide_extension::ExtensionRegistry::new()),
|
||||
);
|
||||
*shared.workspace.write() =
|
||||
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Plugin dependencies at run time: which plugins may start, in which order,
|
||||
//! and what follows when a plugin they need goes away or comes back.
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus, types::*};
|
||||
use ide_extension::{Blocked, Node, Plan, dependency_state, dependents, plan};
|
||||
use ide_plugin::WorkbenchEvent;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Points whose contributions changed since the owners were last told.
|
||||
pub(super) type PendingChanges = Arc<parking_lot::Mutex<BTreeSet<String>>>;
|
||||
|
||||
impl PluginHost {
|
||||
/// The plan for the plugins as they are now. Plugins that are switched off,
|
||||
/// broken or being uninstalled cannot be depended on.
|
||||
fn dependency_plan(&self) -> Plan {
|
||||
self.with_nodes(|_, plan| plan.clone())
|
||||
}
|
||||
|
||||
fn with_nodes<R>(&self, f: impl FnOnce(&[Node<'_>], &Plan) -> R) -> R {
|
||||
let plugins: Vec<Arc<LoadedPlugin>> = self.plugins.read().values().cloned().collect();
|
||||
let disabled = self.state.lock().disabled.clone();
|
||||
let nodes: Vec<Node<'_>> = plugins
|
||||
.iter()
|
||||
.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &p.manifest().depends,
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid && !p.meta.lock().uninstalling,
|
||||
})
|
||||
.collect();
|
||||
let plan = plan(&nodes);
|
||||
f(&nodes, &plan)
|
||||
}
|
||||
|
||||
/// Why `plugin` may not start now, if it may not.
|
||||
pub(super) fn blocked_reason(&self, plugin: &LoadedPlugin) -> Option<Blocked> {
|
||||
if let Some(reason) = self.dependency_plan().blocked.get(plugin.id()) {
|
||||
return Some(reason.clone());
|
||||
}
|
||||
// Enabled and present, but it could not start itself (failed, waiting for consent).
|
||||
let plugins = self.plugins.read();
|
||||
plugin
|
||||
.manifest()
|
||||
.depends
|
||||
.iter()
|
||||
.filter(|d| !d.optional)
|
||||
.find(|d| {
|
||||
plugins.get(&d.id).is_some_and(|p| {
|
||||
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
|
||||
})
|
||||
})
|
||||
.map(|d| Blocked::Dependency { id: d.id.clone() })
|
||||
}
|
||||
|
||||
/// Stops `plugin` and shows why it is not running.
|
||||
pub(super) fn set_blocked(&self, plugin: &LoadedPlugin, reason: &Blocked) {
|
||||
self.stop(plugin);
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Blocked;
|
||||
meta.error = Some(reason.to_string());
|
||||
meta.warnings.clear();
|
||||
tracing::info!(plugin = %plugin.id(), "{reason}");
|
||||
}
|
||||
|
||||
/// Orders `plugins` so each comes after the plugins it needs.
|
||||
pub(super) fn in_start_order(&self, mut plugins: Vec<Arc<LoadedPlugin>>) -> Vec<Arc<LoadedPlugin>> {
|
||||
let order = self.dependency_plan().order;
|
||||
let rank = |id: &str| order.iter().position(|o| o == id).unwrap_or(usize::MAX);
|
||||
plugins.sort_by(|a, b| rank(a.id()).cmp(&rank(b.id())).then_with(|| a.id().cmp(b.id())));
|
||||
plugins
|
||||
}
|
||||
|
||||
/// Brings every plugin in line with the dependency plan: plugins that lost
|
||||
/// something they need stop, plugins that can run again start. Called with
|
||||
/// the lifecycle lock held, after anything that adds, removes, enables or
|
||||
/// disables a plugin.
|
||||
pub(super) fn reconcile(&self) {
|
||||
let plan = self.dependency_plan();
|
||||
let get = |id: &str| self.plugins.read().get(id).cloned();
|
||||
for (id, reason) in &plan.blocked {
|
||||
if let Some(plugin) = get(id)
|
||||
&& (plugin.status() != PluginStatus::Blocked
|
||||
|| plugin.meta.lock().error.as_deref() != Some(&reason.to_string()))
|
||||
{
|
||||
self.set_blocked(&plugin, reason);
|
||||
}
|
||||
}
|
||||
for id in &plan.order {
|
||||
if let Some(plugin) = get(id)
|
||||
&& plugin.status() == PluginStatus::Blocked
|
||||
{
|
||||
self.start(&plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After `id` started again (reloaded, updated): plugins that need it follow,
|
||||
/// and what they contributed is checked against its current extension points.
|
||||
pub(super) fn settle_dependents(&self, id: &str) {
|
||||
self.restart_dependents(id);
|
||||
self.reconcile();
|
||||
}
|
||||
|
||||
/// Starts again the running plugins that need `id`, and those that list it
|
||||
/// as an optional requirement: the latter skipped their contributions to its
|
||||
/// points while it was absent, so they take effect when it appears.
|
||||
fn restart_dependents(&self, id: &str) {
|
||||
let mut ids = self.with_nodes(|nodes, _| dependents(nodes, id));
|
||||
for optional in self.optional_dependents(id) {
|
||||
if !ids.contains(&optional) {
|
||||
ids.push(optional);
|
||||
}
|
||||
}
|
||||
for dependent in ids {
|
||||
self.restart_live(&dependent);
|
||||
}
|
||||
}
|
||||
|
||||
/// After `id` was enabled: the plugins that list it as an optional requirement
|
||||
/// start again (the ones that need it start through [`reconcile`](Self::reconcile)).
|
||||
pub(super) fn restart_optional_dependents(&self, id: &str) {
|
||||
for dependent in self.optional_dependents(id) {
|
||||
self.restart_live(&dependent);
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_dependents(&self, id: &str) -> Vec<String> {
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.manifest().depends.iter().any(|d| d.optional && d.id == id))
|
||||
.map(|p| p.id().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn restart_live(&self, id: &str) {
|
||||
let Some(plugin) = self.plugins.read().get(id).cloned() else { return };
|
||||
if plugin.status().is_live() {
|
||||
self.stop(&plugin);
|
||||
self.start(&plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells plugins that declare an extension point that contributions to it changed.
|
||||
pub(super) fn flush_extension_changes(&self) {
|
||||
let points: Vec<String> = std::mem::take(&mut *self.extension_changes.lock()).into_iter().collect();
|
||||
if !points.is_empty() {
|
||||
self.post_events(None, vec![WorkbenchEvent::ExtensionsChanged(points)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[depends]]` with their state, who needs this plugin, and its extension points.
|
||||
pub(super) fn relations(
|
||||
&self,
|
||||
plugin: &LoadedPlugin,
|
||||
) -> (Vec<DependencyInfo>, Vec<DependentInfo>, Vec<ExtensionPointInfo>) {
|
||||
let disabled = self.state.lock().disabled.clone();
|
||||
let plugins = self.plugins.read();
|
||||
let depends = plugin
|
||||
.manifest()
|
||||
.depends
|
||||
.iter()
|
||||
.map(|dep| {
|
||||
let found = plugins.get(&dep.id);
|
||||
let node = found.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &[],
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid,
|
||||
});
|
||||
let stuck = found.is_some_and(|p| {
|
||||
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
|
||||
});
|
||||
DependencyInfo {
|
||||
id: dep.id.clone(),
|
||||
name: found.map(|p| p.manifest().name.clone()),
|
||||
version: dep.version.clone(),
|
||||
optional: dep.optional,
|
||||
state: dependency_state(dep, node.as_ref(), stuck),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all: Vec<Node<'_>> = plugins
|
||||
.values()
|
||||
.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &p.manifest().depends,
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid,
|
||||
})
|
||||
.collect();
|
||||
let needing = dependents(&all, plugin.id())
|
||||
.into_iter()
|
||||
.filter_map(|id| plugins.get(&id))
|
||||
.map(|p| DependentInfo {
|
||||
id: p.id().to_owned(),
|
||||
name: p.manifest().name.clone(),
|
||||
enabled: !disabled.contains(p.id()),
|
||||
})
|
||||
.collect();
|
||||
let points = self
|
||||
.registry
|
||||
.declared_points(plugin.id())
|
||||
.into_iter()
|
||||
.map(|p| ExtensionPointInfo {
|
||||
id: p.decl.id,
|
||||
description: p.decl.description,
|
||||
contributors: p.contributors,
|
||||
})
|
||||
.collect();
|
||||
(depends, needing, points)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
mod dependencies;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
@@ -369,6 +370,8 @@ pub struct PluginHost {
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
limits: Limits,
|
||||
registry: Arc<ExtensionRegistry>,
|
||||
/// Extension points (declared by plugins) whose contributions changed, until their owners are told.
|
||||
extension_changes: dependencies::PendingChanges,
|
||||
ui: Arc<UiStore>,
|
||||
languages: Arc<PluginLanguages>,
|
||||
run_detectors: Arc<run_detectors::RunDetectors>,
|
||||
@@ -436,6 +439,11 @@ impl PluginHost {
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let registry = Arc::new(ExtensionRegistry::new());
|
||||
let extension_changes: dependencies::PendingChanges = Arc::default();
|
||||
let changes = extension_changes.clone();
|
||||
registry.set_change_listener(Arc::new(move |point, _owner| {
|
||||
changes.lock().insert(point.to_owned());
|
||||
}));
|
||||
let ui = UiStore::new();
|
||||
let index = Arc::new(ContributionIndex::new(
|
||||
plugins_dir.join(".cache").join("contributions"),
|
||||
@@ -453,8 +461,14 @@ impl PluginHost {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
|
||||
let shared =
|
||||
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
|
||||
let shared = api::Shared::new(
|
||||
sink.clone(),
|
||||
plugins_dir.join(".data"),
|
||||
pins.clone(),
|
||||
ui.clone(),
|
||||
ledger.clone(),
|
||||
registry.clone(),
|
||||
);
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
@@ -470,6 +484,7 @@ impl PluginHost {
|
||||
sink,
|
||||
limits,
|
||||
registry,
|
||||
extension_changes,
|
||||
ui,
|
||||
languages,
|
||||
run_detectors,
|
||||
@@ -556,16 +571,22 @@ impl PluginHost {
|
||||
}
|
||||
let dirs = package_dirs(&self.plugins_dir);
|
||||
self.migrate_grants(&dirs);
|
||||
// Every package is listed before any starts: a plugin's requirements are
|
||||
// decided against all of them, and each starts after the plugins it needs.
|
||||
let mut to_start = Vec::new();
|
||||
for dir in dirs {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Installed, None);
|
||||
to_start.extend(self.register_dir(&dir, PluginSource::Installed, None).1);
|
||||
}
|
||||
}
|
||||
for dir in self.links().paths {
|
||||
if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() {
|
||||
self.load_dir(&dir, PluginSource::Linked, None);
|
||||
to_start.extend(self.register_dir(&dir, PluginSource::Linked, None).1);
|
||||
}
|
||||
}
|
||||
for plugin in self.in_start_order(to_start) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
let (done, cv) = &self.initial_load;
|
||||
*done.lock() = true;
|
||||
cv.notify_all();
|
||||
@@ -742,6 +763,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.reconcile();
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
@@ -841,6 +863,11 @@ impl PluginHost {
|
||||
} else {
|
||||
plugin.meta.lock().status = PluginStatus::Disabled;
|
||||
}
|
||||
// Plugins that need this one stop or start with it.
|
||||
self.reconcile();
|
||||
if enabled {
|
||||
self.restart_optional_dependents(id);
|
||||
}
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -853,6 +880,7 @@ impl PluginHost {
|
||||
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
|
||||
self.unload(id);
|
||||
let new_id = self.load_dir(&dir, source, root);
|
||||
self.settle_dependents(&new_id);
|
||||
self.changed();
|
||||
self.info_of(&new_id)
|
||||
}
|
||||
@@ -971,6 +999,7 @@ impl PluginHost {
|
||||
.map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into()));
|
||||
if let Some(reason) = failed {
|
||||
self.roll_back(&id, &previous, &version, old_grant);
|
||||
self.settle_dependents(&id);
|
||||
self.changed();
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} {version} could not start ({reason}); version {previous} was restored",
|
||||
@@ -983,6 +1012,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
install::prune(&root);
|
||||
self.settle_dependents(&id);
|
||||
self.changed();
|
||||
self.info_of(&id)
|
||||
}
|
||||
@@ -1082,6 +1112,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1102,6 +1134,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1137,6 +1171,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1208,6 +1244,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1316,6 +1354,7 @@ impl PluginHost {
|
||||
return Err(e.into());
|
||||
}
|
||||
self.unload(id);
|
||||
self.reconcile();
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1484,7 +1523,7 @@ impl PluginHost {
|
||||
PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")),
|
||||
PluginStatus::Static => Some(format!("plugin {id:?} has no code")),
|
||||
PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")),
|
||||
PluginStatus::Failed => {
|
||||
PluginStatus::Failed | PluginStatus::Blocked => {
|
||||
Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")))
|
||||
}
|
||||
PluginStatus::Active | PluginStatus::Inactive => None,
|
||||
@@ -1858,6 +1897,15 @@ impl PluginHost {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
// Only the plugin that declares a point hears about its contributions.
|
||||
WorkbenchEvent::ExtensionsChanged(points) => {
|
||||
let own: Vec<String> = points
|
||||
.iter()
|
||||
.filter(|point| self.registry.point_owner(point).as_deref() == Some(p.id()))
|
||||
.cloned()
|
||||
.collect();
|
||||
(!own.is_empty()).then_some(WorkbenchEvent::ExtensionsChanged(own))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.collect();
|
||||
@@ -2388,6 +2436,21 @@ impl PluginHost {
|
||||
|
||||
/// Loads one package and starts it unless disabled. Returns the plugin id.
|
||||
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
|
||||
let (id, to_start) = self.register_dir(dir, source, dev_root);
|
||||
if let Some(plugin) = to_start {
|
||||
self.start(&plugin);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Reads one package and lists it as a plugin. Returns its id and the plugin
|
||||
/// when it is to be started (valid, enabled, not already loaded).
|
||||
fn register_dir(
|
||||
&self,
|
||||
dir: &Path,
|
||||
source: PluginSource,
|
||||
dev_root: Option<PathBuf>,
|
||||
) -> (String, Option<Arc<LoadedPlugin>>) {
|
||||
let (manifest, error) = match Manifest::load(dir) {
|
||||
Ok(m) => (m, None),
|
||||
Err(e) => {
|
||||
@@ -2430,7 +2493,7 @@ impl PluginHost {
|
||||
dir.display(),
|
||||
existing.dir().display()
|
||||
);
|
||||
return id;
|
||||
return (id, None);
|
||||
}
|
||||
plugins.insert(id.clone(), plugin.clone());
|
||||
}
|
||||
@@ -2439,10 +2502,10 @@ impl PluginHost {
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(e);
|
||||
} else if !self.state.lock().disabled.contains(&id) {
|
||||
self.start(&plugin);
|
||||
return (id, None);
|
||||
}
|
||||
id
|
||||
let enabled = !self.state.lock().disabled.contains(&id);
|
||||
(id, enabled.then_some(plugin))
|
||||
}
|
||||
|
||||
fn unload(&self, id: &str) {
|
||||
@@ -2469,6 +2532,9 @@ impl PluginHost {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(reason) = self.blocked_reason(plugin) {
|
||||
return self.set_blocked(plugin, &reason);
|
||||
}
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
if plugin.source == PluginSource::Installed
|
||||
@@ -2771,6 +2837,7 @@ impl PluginHost {
|
||||
self.refresh_pins(&ledger);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.flush_extension_changes();
|
||||
let languages = self.languages.revision();
|
||||
if self.announced_languages.swap(languages, Ordering::AcqRel) != languages {
|
||||
self.emit(None, PluginEvent::LanguagesChanged);
|
||||
@@ -2929,6 +2996,8 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
|
||||
// Before `meta` below: this reads the other plugins' statuses.
|
||||
let (depends, dependents, extension_points) = self.relations(p);
|
||||
let m = p.manifest();
|
||||
// The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the
|
||||
// ledger and locks each plugin's): the other order deadlocks.
|
||||
@@ -2974,6 +3043,9 @@ impl PluginHost {
|
||||
previous_version: (p.source == PluginSource::Installed)
|
||||
.then(|| install::previous_version(&self.plugins_dir.join(&m.id)))
|
||||
.flatten(),
|
||||
depends,
|
||||
dependents,
|
||||
extension_points,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3075,6 +3147,8 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
|
||||
wasm: None,
|
||||
activation: Vec::new(),
|
||||
capabilities: Default::default(),
|
||||
depends: Vec::new(),
|
||||
extension_points: Vec::new(),
|
||||
contributes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ pub enum PluginStatus {
|
||||
NeedsConsent,
|
||||
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
|
||||
Failed,
|
||||
/// A plugin it requires is missing, disabled, the wrong version or cannot run
|
||||
/// (`error` says which); it starts when that is resolved.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
@@ -87,6 +90,45 @@ pub struct PluginInfo {
|
||||
pub publisher: Option<String>,
|
||||
/// The version an update replaced, to roll back to.
|
||||
pub previous_version: Option<String>,
|
||||
/// `[[depends]]`, each with how it stands now.
|
||||
pub depends: Vec<DependencyInfo>,
|
||||
/// Plugins that need this one, directly or through others.
|
||||
pub dependents: Vec<DependentInfo>,
|
||||
/// `[[extension-points]]` this plugin declares while it is live.
|
||||
pub extension_points: Vec<ExtensionPointInfo>,
|
||||
}
|
||||
|
||||
/// A plugin another one requires.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyInfo {
|
||||
pub id: String,
|
||||
/// The installed plugin's name, when it is installed.
|
||||
pub name: Option<String>,
|
||||
/// The requirement as written (`^0.2`).
|
||||
pub version: Option<String>,
|
||||
pub optional: bool,
|
||||
pub state: ide_extension::DependencyState,
|
||||
}
|
||||
|
||||
/// A plugin that needs another one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependentInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Switched off by the user: disabling the plugin it needs changes nothing for it.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// An extension point a plugin declared.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtensionPointInfo {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
/// Plugin ids contributing to it now.
|
||||
pub contributors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -13,9 +13,11 @@ use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
@@ -529,7 +531,7 @@ impl Host {
|
||||
mark("git");
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key);
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
@@ -611,6 +613,7 @@ impl Host {
|
||||
&self,
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
settings: &Arc<SettingsStore>,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
@@ -640,6 +643,10 @@ impl Host {
|
||||
},
|
||||
self.runtime.clone(),
|
||||
));
|
||||
let settings = settings.clone();
|
||||
agents.set_mcp_servers_hook(Box::new(move || {
|
||||
ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers)
|
||||
}));
|
||||
(db, http_history, agents)
|
||||
}
|
||||
}
|
||||
@@ -681,7 +688,7 @@ impl Host {
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
@@ -1197,9 +1204,81 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
/// The roots a file may navigate into: its own root (the workspace member containing it, or
|
||||
/// the project root) plus that root's configured dependencies. A workspace member depends
|
||||
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
|
||||
/// being attached to the workspace never makes its code navigable by itself.
|
||||
/// Cached navigation roots per (project, file), with the instant they were computed.
|
||||
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
|
||||
|
||||
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
// Hover probes and import-segment clicks repeat these lookups for the same file,
|
||||
// and each uncached call re-reads the workspace file: memoize briefly. The short
|
||||
// TTL keeps settings and workspace edits near-immediate.
|
||||
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const CAP: usize = 256;
|
||||
let key = (project.root.clone(), from.to_path_buf());
|
||||
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return roots.clone();
|
||||
}
|
||||
let roots = navigation_roots_uncached(project, from);
|
||||
if let Some(map) = CACHE.lock().as_mut() {
|
||||
if map.len() >= CAP {
|
||||
map.retain(|_, (at, _)| at.elapsed() < TTL);
|
||||
}
|
||||
map.insert(key, (Instant::now(), roots.clone()));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
let settings = project.settings.effective();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
let own = workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
|
||||
.unwrap_or_else(|| project.root.clone());
|
||||
let mut roots = vec![own.clone()];
|
||||
let own_key = ide_core::ipc_path::to_ipc(&own);
|
||||
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
|
||||
for dep in deps {
|
||||
let dep = PathBuf::from(dep.trim());
|
||||
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
|
||||
roots.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
if workspace.is_none() {
|
||||
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
|
||||
if !roots.contains(&root) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
|
||||
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
|
||||
/// so compare normalized rather than only by exact match.
|
||||
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
|
||||
if let Some(deps) = map.get(own_key) {
|
||||
return Some(deps);
|
||||
}
|
||||
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
|
||||
}
|
||||
|
||||
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
let user = settings
|
||||
.project_structure
|
||||
.content_roots
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
@@ -1811,3 +1890,34 @@ mod composer_tests {
|
||||
assert!(!changed(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_roots_tests {
|
||||
use super::content_roots;
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn member_dependencies_match_hand_written_keys() {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
|
||||
let deps = super::member_dependencies_for(&map, "B:/proj/member");
|
||||
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_projects_join_the_content_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
let shared = tmp.path().join("shared");
|
||||
let dep = tmp.path().join("lib");
|
||||
for dir in [&root, &shared, &dep] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
|
||||
settings.project_structure.dependencies =
|
||||
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
|
||||
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
|
||||
assert_eq!(roots, vec![shared, dep]);
|
||||
}
|
||||
}
|
||||
@@ -335,6 +335,11 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "agent_registry_list"
|
||||
| "agent_registry_install"
|
||||
| "agent_registry_remove"
|
||||
| "agent_mcp_list"
|
||||
| "agent_mcp_save"
|
||||
| "agent_mcp_set_enabled"
|
||||
| "agent_mcp_remove"
|
||||
| "agent_mcp_import"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "user_font_data"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Plugin dependencies and plugin-declared extension points through a real WASM owner
|
||||
//! (`ide-plugin/tests/fixtures/ext-owner`) and data-only dependents.
|
||||
use ide_extension::DependencyState;
|
||||
use ide_host::plugins::{Consent, PluginHost, PluginInfo, PluginStatus};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const OWNER: &str = "test.ext-owner";
|
||||
const POINT: &str = "test.ext-owner.items";
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
plugins_dir: PathBuf,
|
||||
host: PluginHost,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let plugins_dir = root.join("plugins");
|
||||
let host = Self::host_on(&plugins_dir);
|
||||
host.load_all();
|
||||
Self { _tmp: tmp, plugins_dir, host }
|
||||
}
|
||||
|
||||
fn host_on(plugins_dir: &Path) -> PluginHost {
|
||||
PluginHost::new(plugins_dir.to_owned(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Installs the real owner plugin, optionally at another version.
|
||||
fn install_owner(&self, version: &str) -> Result<PluginInfo, String> {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/ext-owner");
|
||||
let pkg = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(pkg.path().join("dist")).unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), pkg.path().join("dist/plugin.wasm")).unwrap();
|
||||
let toml = std::fs::read_to_string(fixture.join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.path().join("plugin.toml"),
|
||||
toml.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
.unwrap();
|
||||
self.install_dir(pkg.path())
|
||||
}
|
||||
|
||||
/// Installs a plugin made of a manifest only (contributions, no code).
|
||||
fn install_data(&self, id: &str, version: &str, body: &str) -> Result<PluginInfo, String> {
|
||||
let pkg = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"{version}\"\nengines.openidea = \"^1.5\"\n{body}");
|
||||
std::fs::write(pkg.path().join("plugin.toml"), manifest).unwrap();
|
||||
self.install_dir(pkg.path())
|
||||
}
|
||||
|
||||
fn install_dir(&self, dir: &Path) -> Result<PluginInfo, String> {
|
||||
self.host.install_from_dir(dir, &Consent::all()).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
fn contributor(&self) -> Result<PluginInfo, String> {
|
||||
self.install_data(
|
||||
"test.ext-contributor",
|
||||
"0.2.0",
|
||||
&format!(
|
||||
"[[depends]]\nid = \"{OWNER}\"\nversion = \"^0.1\"\n\n[[contributes.\"{POINT}\"]]\nname = \"from-contributor\"\nweight = 7\n"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn run(&self, plugin: &str, command: &str, arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
let inv = Invocation { client: Some("main".into()), ..Default::default() };
|
||||
self.host.run_command(plugin, command, arg, inv).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
fn info(&self, id: &str) -> PluginInfo {
|
||||
self.host.info_of(id).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dependent_contributes_data_that_reaches_the_owners_code() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
|
||||
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
|
||||
assert_eq!(dump, r#"test.ext-contributor:{"name":"from-contributor","weight":7}"#);
|
||||
|
||||
let owner = f.info(OWNER);
|
||||
assert_eq!(owner.extension_points.len(), 1);
|
||||
assert_eq!(owner.extension_points[0].id, POINT);
|
||||
assert_eq!(owner.extension_points[0].contributors, ["test.ext-contributor"]);
|
||||
assert_eq!(owner.dependents.iter().map(|d| d.id.as_str()).collect::<Vec<_>>(), ["test.ext-contributor"]);
|
||||
|
||||
let contributor = f.info("test.ext-contributor");
|
||||
assert_eq!(contributor.status, PluginStatus::Static);
|
||||
assert_eq!(contributor.depends.len(), 1);
|
||||
assert_eq!(contributor.depends[0].state, DependencyState::Satisfied);
|
||||
assert_eq!(contributor.depends[0].name.as_deref(), Some("Extension point owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_waits_for_the_plugin_it_needs_and_starts_when_it_arrives() {
|
||||
let f = Fixture::new();
|
||||
let waiting = f.contributor().unwrap();
|
||||
assert_eq!(waiting.status, PluginStatus::Blocked);
|
||||
assert_eq!(waiting.error.as_deref(), Some("Requires test.ext-owner ^0.1, which is not installed"));
|
||||
assert_eq!(waiting.depends[0].state, DependencyState::Missing);
|
||||
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static, "started with its dependency");
|
||||
assert_eq!(f.info("test.ext-contributor").error, None);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_plugin_stops_what_needs_it_and_enabling_brings_it_back() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
|
||||
f.host.set_enabled(OWNER, false).unwrap();
|
||||
let blocked = f.info("test.ext-contributor");
|
||||
assert_eq!(blocked.status, PluginStatus::Blocked);
|
||||
assert_eq!(blocked.error.as_deref(), Some("Requires test.ext-owner, which is disabled"));
|
||||
assert_eq!(blocked.depends[0].state, DependencyState::Disabled);
|
||||
assert!(blocked.contributions.is_empty(), "its contributions are withdrawn");
|
||||
|
||||
f.host.set_enabled(OWNER, true).unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_the_requirement_does_not_accept_blocks() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.3.0").unwrap();
|
||||
let info = f.contributor().unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Blocked);
|
||||
assert_eq!(info.error.as_deref(), Some("Requires test.ext-owner ^0.1, but version 0.3.0 is installed"));
|
||||
assert_eq!(info.depends[0].state, DependencyState::Version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_the_owner_checks_its_dependents_again() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
|
||||
f.install_owner("0.3.0").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked, "^0.1 no longer matches");
|
||||
|
||||
f.install_owner("0.1.5").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_that_need_each_other_do_not_start_and_say_so() {
|
||||
let f = Fixture::new();
|
||||
let a = f.install_data("test.cycle-a", "1.0.0", "[[depends]]\nid = \"test.cycle-b\"\n").unwrap();
|
||||
assert_eq!(a.status, PluginStatus::Blocked, "b is not installed yet");
|
||||
let b = f.install_data("test.cycle-b", "1.0.0", "[[depends]]\nid = \"test.cycle-a\"\n").unwrap();
|
||||
for info in [b, f.info("test.cycle-a")] {
|
||||
assert_eq!(info.status, PluginStatus::Blocked, "{}", info.id);
|
||||
assert_eq!(
|
||||
info.error.as_deref(),
|
||||
Some("Part of a dependency cycle: test.cycle-a → test.cycle-b → test.cycle-a"),
|
||||
"{}",
|
||||
info.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_of_the_app_starts_plugins_after_the_ones_they_need() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
f.host.shutdown();
|
||||
|
||||
// "test.ext-contributor" sorts before "test.ext-owner": directory order would start it first.
|
||||
let again = Fixture::host_on(&f.plugins_dir);
|
||||
again.load_all();
|
||||
assert_eq!(again.info_of("test.ext-contributor").unwrap().status, PluginStatus::Static);
|
||||
let inv = Invocation { client: Some("main".into()), ..Default::default() };
|
||||
let dump = again.run_command(OWNER, "dump", None, inv).unwrap().unwrap();
|
||||
assert!(dump.contains("from-contributor"), "{dump}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_declaring_plugin_reads_a_point_and_unknown_points_are_not_found() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.install_data(
|
||||
"test.other",
|
||||
"1.0.0",
|
||||
"[[extension-points]]\nid = \"test.other.things\"\nfields = { name = \"string\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(f.run(OWNER, "read", Some(POINT)).unwrap().as_deref(), Some("0"));
|
||||
let denied = f.run(OWNER, "read", Some("test.other.things")).unwrap_err();
|
||||
assert!(denied.contains("denied") || denied.contains("permission"), "{denied}");
|
||||
let missing = f.run(OWNER, "read", Some("test.nobody.things")).unwrap_err();
|
||||
assert!(missing.contains("no running plugin declares"), "{missing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributions_are_checked_against_the_fields_the_point_declares() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
let body = format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nweight = 1\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("missing required field \"name\""), "{err}");
|
||||
|
||||
let body =
|
||||
format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nname = \"x\"\ncolour = \"red\"\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("unknown field \"colour\""), "{err}");
|
||||
|
||||
let body = format!("[[contributes.\"{POINT}\"]]\nname = \"x\"\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("[[depends]]"), "contributing needs the dependency: {err}");
|
||||
}
|
||||
|
||||
/// An optional requirement never blocks, but the plugin still contributes once the plugin
|
||||
/// appears (IntelliJ loads an optional `depends` part when its target is installed or enabled later).
|
||||
#[test]
|
||||
fn an_optional_dependent_contributes_when_its_target_appears_later() {
|
||||
let f = Fixture::new();
|
||||
let body = format!(
|
||||
"[[depends]]\nid = \"{OWNER}\"\noptional = true\n\n[[contributes.\"{POINT}\"]]\nname = \"optional-one\"\n"
|
||||
);
|
||||
let early = f.install_data("test.optional", "1.0.0", &body).unwrap();
|
||||
assert_eq!(early.status, PluginStatus::Static, "an optional requirement never blocks: {:?}", early.error);
|
||||
assert_eq!(early.depends[0].state, DependencyState::Missing);
|
||||
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
|
||||
assert!(dump.contains("optional-one"), "contributed once the owner appeared: {dump:?}");
|
||||
|
||||
// Off and on again: it is back without restarting anything.
|
||||
f.host.set_enabled(OWNER, false).unwrap();
|
||||
assert_eq!(f.info("test.optional").status, PluginStatus::Static, "optional: not blocked");
|
||||
f.host.set_enabled(OWNER, true).unwrap();
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("optional-one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_hears_that_contributions_changed() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
// The owner is running when the contribution arrives.
|
||||
f.run(OWNER, "dump", None).unwrap();
|
||||
f.contributor().unwrap();
|
||||
f.host.flush_events();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)), "the event was delivered");
|
||||
let seen = f.run(OWNER, "changes", None).unwrap().unwrap();
|
||||
assert!(seen.contains(POINT), "{seen}");
|
||||
|
||||
// Only the owner hears: a plugin that declares nothing is not woken for it.
|
||||
let other = f.info("test.ext-contributor");
|
||||
assert_eq!(other.stats.activations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dependent_follows_its_dependency_through_permission_changes() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
|
||||
// Revoking a permission the owner requires stops it, and what needs it with it.
|
||||
f.host.set_capability(OWNER, "storage", false).unwrap();
|
||||
assert_ne!(f.info(OWNER).status, PluginStatus::Active);
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked);
|
||||
|
||||
// Granting it again starts both, without touching the dependent.
|
||||
f.host.set_capability(OWNER, "storage", true).unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
@@ -98,6 +98,44 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
/// Find in Files over the fixture's files on disk.
|
||||
fn search_text(
|
||||
&self,
|
||||
_client: Option<&str>,
|
||||
root: &Path,
|
||||
query: ide_plugin::TextQuery,
|
||||
limit: u32,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<ide_plugin::TextSearch, HostError> {
|
||||
let query = ide_search::SearchQuery {
|
||||
pattern: query.pattern,
|
||||
is_regex: query.regex,
|
||||
case_sensitive: query.case_sensitive,
|
||||
whole_word: query.whole_word,
|
||||
include: query.include,
|
||||
exclude: query.exclude,
|
||||
scope: ide_search::SearchScope::Project,
|
||||
max_matches: limit,
|
||||
context: Default::default(),
|
||||
};
|
||||
let files = Mutex::new(Vec::new());
|
||||
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
|
||||
.map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let matches = files
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.flat_map(|file| {
|
||||
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
|
||||
location: ide_plugin::Location {
|
||||
path: PathBuf::from(&file.path.0),
|
||||
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
|
||||
},
|
||||
line: m.text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if path.ends_with("event-barrier.txt") {
|
||||
let gate = self.event_read_gate.lock().take();
|
||||
@@ -451,6 +489,29 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
|
||||
/// wants UTF-8, and follows `read-file`'s confinement rules.
|
||||
#[test]
|
||||
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_pkg());
|
||||
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
|
||||
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
|
||||
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
|
||||
let abs = f.project.join("logo.bin");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
|
||||
// The same refusal and not-found behaviour as `read-file`; like every
|
||||
// refusal, it is the plugin's to handle: it keeps running.
|
||||
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `fs.read` reaches the granted paths and nothing else, through the real
|
||||
/// component: the same checks the runtime makes, against a real file system.
|
||||
#[test]
|
||||
@@ -2740,9 +2801,12 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
|
||||
std::fs::write(&java_path, java).unwrap();
|
||||
std::fs::write(&xml_path, xml).unwrap();
|
||||
// Noise the index has to skip.
|
||||
for i in 0..200 {
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
|
||||
// Noise: other mappers, some large, that the lookup must not read one by one.
|
||||
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
|
||||
for i in 0..600 {
|
||||
let body = if i % 50 == 0 { filler.as_str() } else { "" };
|
||||
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
|
||||
}
|
||||
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
@@ -2751,9 +2815,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
text: text.into(),
|
||||
};
|
||||
let jdoc = doc(&java_path, "java", java);
|
||||
// The first call indexes the XML files; on a slow machine that runs past the
|
||||
// edit-path budget, so the answer is dropped (the index is kept) and calls
|
||||
// queued behind it are skipped. The editor asks again; so does this.
|
||||
// The first call searches the XML files; on a slow machine that can run past the
|
||||
// edit-path budget, so the answer is dropped and calls queued behind it are
|
||||
// skipped. The editor asks again; so does this.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let markers = loop {
|
||||
let markers = f.host.provide_line_markers(&jdoc);
|
||||
@@ -2782,7 +2846,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
"not its language"
|
||||
);
|
||||
|
||||
// Warm (index cached): well inside the edit-path budget.
|
||||
// Warm (lookup cached): well inside the edit-path budget.
|
||||
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..20 {
|
||||
@@ -5723,6 +5787,7 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("read-bytes", "a.txt", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
|
||||
@@ -157,3 +157,22 @@ async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
|
||||
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
|
||||
assert!(escaped.is_err(), "{escaped:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn debug_env_files_load_in_order_like_a_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let first = tmp.path().join("first.env");
|
||||
let second = tmp.path().join("second.env");
|
||||
std::fs::write(&first, "SHARED=one\nONLY_FIRST=1\nREF=${SHARED}-suffix\n").unwrap();
|
||||
std::fs::write(&second, "SHARED=two\nONLY_SECOND=2\n").unwrap();
|
||||
let vars = ops::run::run_env_files(&host, "main", vec![first, second]).unwrap();
|
||||
// Later files win; references resolve against earlier files, then the system environment.
|
||||
assert_eq!(vars.get("SHARED").map(String::as_str), Some("two"));
|
||||
assert_eq!(vars.get("ONLY_FIRST").map(String::as_str), Some("1"));
|
||||
assert_eq!(vars.get("ONLY_SECOND").map(String::as_str), Some("2"));
|
||||
assert_eq!(vars.get("REF").map(String::as_str), Some("one-suffix"));
|
||||
// An unreadable file is an error, like a run that names a missing `.env`.
|
||||
let missing = ops::run::run_env_files(&host, "main", vec![tmp.path().join("nope.env")]);
|
||||
assert!(missing.is_err(), "{missing:?}");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! cURL ⇄ `.http` request: Convert to cURL and Copy, and the conversion of a pasted
|
||||
//! `curl …` command into a request.
|
||||
|
||||
use crate::PreparedRequest;
|
||||
|
||||
/// `req` as a cURL command line, one option per line (POSIX shell quoting).
|
||||
pub fn to_curl(req: &PreparedRequest) -> String {
|
||||
let mut parts = vec![format!("curl -X {} {}", req.method, quote(&req.url))];
|
||||
for (k, v) in &req.headers {
|
||||
parts.push(format!("-H {}", quote(&format!("{k}: {v}"))));
|
||||
}
|
||||
if let Some(body) = &req.body {
|
||||
parts.push(format!("--data-raw {}", quote(body)));
|
||||
}
|
||||
parts.join(" \\\n ")
|
||||
}
|
||||
|
||||
/// `s` in single quotes, as POSIX shells read it.
|
||||
fn quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// The request a `curl …` command sends, as `.http` text; `None` when `cmd` is not a cURL
|
||||
/// command or names no URL.
|
||||
pub fn from_curl(cmd: &str) -> Option<String> {
|
||||
let words = split_words(cmd)?;
|
||||
let mut args = words.iter().map(String::as_str);
|
||||
if args.next() != Some("curl") {
|
||||
return None;
|
||||
}
|
||||
let mut method: Option<String> = None;
|
||||
let mut url: Option<String> = None;
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
let mut data: Vec<String> = Vec::new();
|
||||
let mut form: Vec<(String, String)> = Vec::new();
|
||||
let mut get = false;
|
||||
let header = |headers: &mut Vec<(String, String)>, line: &str| {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
headers.push((k.trim().to_owned(), v.trim().to_owned()));
|
||||
}
|
||||
};
|
||||
while let Some(arg) = args.next() {
|
||||
// `--opt=value` and `-Xvalue` forms.
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((f, v)) if arg.starts_with("--") => (f, Some(v.to_owned())),
|
||||
_ if arg.len() > 2 && arg.starts_with('-') && !arg.starts_with("--") && takes_value(&arg[..2]) => {
|
||||
(&arg[..2], Some(arg[2..].to_owned()))
|
||||
}
|
||||
_ => (arg, None),
|
||||
};
|
||||
let mut value = || inline.clone().or_else(|| args.next().map(str::to_owned));
|
||||
match flag {
|
||||
"-X" | "--request" => method = value(),
|
||||
"-H" | "--header" => header(&mut headers, &value()?),
|
||||
"-d" | "--data" | "--data-raw" | "--data-binary" | "--data-ascii" | "--data-urlencode" => {
|
||||
data.push(value()?)
|
||||
}
|
||||
"--json" => {
|
||||
data.push(value()?);
|
||||
headers.push(("Content-Type".into(), "application/json".into()));
|
||||
headers.push(("Accept".into(), "application/json".into()));
|
||||
}
|
||||
"-F" | "--form" | "--form-string" => {
|
||||
let v = value()?;
|
||||
let (k, v) = v.split_once('=')?;
|
||||
form.push((k.to_owned(), v.to_owned()));
|
||||
}
|
||||
"-u" | "--user" => {
|
||||
let v = value()?;
|
||||
let (user, pass) = v.split_once(':').unwrap_or((&v, ""));
|
||||
headers.push(("Authorization".into(), format!("Basic {user} {pass}").trim_end().to_owned()));
|
||||
}
|
||||
"-b" | "--cookie" => headers.push(("Cookie".into(), value()?)),
|
||||
"-A" | "--user-agent" => headers.push(("User-Agent".into(), value()?)),
|
||||
"-e" | "--referer" => headers.push(("Referer".into(), value()?)),
|
||||
"--url" => url = value(),
|
||||
"-G" | "--get" => get = true,
|
||||
f if f.starts_with('-') => {
|
||||
// Options with a value the request doesn't need (timeouts, output, proxy…).
|
||||
if takes_value(f) {
|
||||
value();
|
||||
}
|
||||
}
|
||||
u => {
|
||||
if url.is_none() {
|
||||
url = Some(u.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut url = url?;
|
||||
if get && !data.is_empty() {
|
||||
url.push(if url.contains('?') { '&' } else { '?' });
|
||||
url.push_str(&data.join("&"));
|
||||
data.clear();
|
||||
}
|
||||
let method = method.unwrap_or_else(|| if data.is_empty() && form.is_empty() { "GET" } else { "POST" }.into());
|
||||
let mut body = None;
|
||||
if !form.is_empty() {
|
||||
const BOUNDARY: &str = "WebAppBoundary";
|
||||
headers.retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
|
||||
headers.push(("Content-Type".into(), format!("multipart/form-data; boundary={BOUNDARY}")));
|
||||
let mut text = String::new();
|
||||
for (name, value) in &form {
|
||||
text.push_str(&format!("--{BOUNDARY}\n"));
|
||||
match value.strip_prefix('@') {
|
||||
Some(file) => {
|
||||
let file = file.split(';').next().unwrap_or(file);
|
||||
let base = file.rsplit(['/', '\\']).next().unwrap_or(file);
|
||||
text.push_str(&format!(
|
||||
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{base}\"\n\n< {file}\n"
|
||||
));
|
||||
}
|
||||
None => text.push_str(&format!("Content-Disposition: form-data; name=\"{name}\"\n\n{value}\n")),
|
||||
}
|
||||
}
|
||||
text.push_str(&format!("--{BOUNDARY}--"));
|
||||
body = Some(text);
|
||||
} else if !data.is_empty() {
|
||||
if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
|
||||
headers.push(("Content-Type".into(), "application/x-www-form-urlencoded".into()));
|
||||
}
|
||||
body = Some(data.join("&"));
|
||||
}
|
||||
let mut out = format!("{method} {url}\n");
|
||||
for (k, v) in &headers {
|
||||
out.push_str(&format!("{k}: {v}\n"));
|
||||
}
|
||||
if let Some(body) = body {
|
||||
out.push('\n');
|
||||
out.push_str(&body);
|
||||
out.push('\n');
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Options that take a value, so it isn't read as the URL.
|
||||
fn takes_value(flag: &str) -> bool {
|
||||
matches!(
|
||||
flag,
|
||||
"-X" | "-H"
|
||||
| "-d"
|
||||
| "-F"
|
||||
| "-u"
|
||||
| "-b"
|
||||
| "-A"
|
||||
| "-e"
|
||||
| "-o"
|
||||
| "-x"
|
||||
| "-m"
|
||||
| "-c"
|
||||
| "-E"
|
||||
| "-T"
|
||||
| "-w"
|
||||
| "-r"
|
||||
| "-K"
|
||||
| "--output"
|
||||
| "--proxy"
|
||||
| "--max-time"
|
||||
| "--connect-timeout"
|
||||
| "--cookie-jar"
|
||||
| "--cert"
|
||||
| "--key"
|
||||
| "--cacert"
|
||||
| "--upload-file"
|
||||
| "--write-out"
|
||||
| "--range"
|
||||
| "--config"
|
||||
| "--retry"
|
||||
| "--resolve"
|
||||
| "--interface"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell words of `cmd`: single and double quotes, backslash escapes and line continuations
|
||||
/// (also `^` / backtick continuations from Windows shells). `None` for an unclosed quote.
|
||||
fn split_words(cmd: &str) -> Option<Vec<String>> {
|
||||
let mut words = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_word = false;
|
||||
let mut chars = cmd.trim().chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'\'' => break,
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => cur.push(chars.next()?),
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' | '^' | '`' if matches!(chars.peek(), Some('\n' | '\r')) => {
|
||||
while matches!(chars.peek(), Some('\n' | '\r')) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
'\\' => {
|
||||
in_word = true;
|
||||
if let Some(n) = chars.next() {
|
||||
cur.push(n);
|
||||
}
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut cur));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
in_word = true;
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_word {
|
||||
words.push(cur);
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_prepared_request_becomes_a_quoted_curl_command() {
|
||||
let req = PreparedRequest {
|
||||
name: None,
|
||||
method: "POST".into(),
|
||||
url: "https://api.example.com/users?q=a b".into(),
|
||||
headers: vec![("Content-Type".into(), "application/json".into())],
|
||||
body: Some("{\"name\": \"O'Brien\"}".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
to_curl(&req),
|
||||
"curl -X POST 'https://api.example.com/users?q=a b' \\\n -H 'Content-Type: application/json' \\\n --data-raw '{\"name\": \"O'\\''Brien\"}'"
|
||||
);
|
||||
// And back.
|
||||
let http = from_curl(&to_curl(&req)).unwrap();
|
||||
assert_eq!(
|
||||
http,
|
||||
"POST https://api.example.com/users?q=a b\nContent-Type: application/json\n\n{\"name\": \"O'Brien\"}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_copied_command_becomes_a_request() {
|
||||
let cmd = r#"curl 'https://example.com/api/items' \
|
||||
-H 'accept: application/json' \
|
||||
-H "authorization: Bearer abc" \
|
||||
--data-raw '{"a":1}' \
|
||||
--compressed -k -s"#;
|
||||
assert_eq!(
|
||||
from_curl(cmd).unwrap(),
|
||||
"POST https://example.com/api/items\naccept: application/json\nauthorization: Bearer abc\nContent-Type: application/x-www-form-urlencoded\n\n{\"a\":1}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_map_to_headers_query_and_multipart() {
|
||||
assert_eq!(
|
||||
from_curl("curl -G https://x.dev/s -d q=1 -d page=2 -u bob:secret -A agent --max-time 5").unwrap(),
|
||||
"GET https://x.dev/s?q=1&page=2\nAuthorization: Basic bob secret\nUser-Agent: agent\n"
|
||||
);
|
||||
assert_eq!(
|
||||
from_curl("curl -XPUT --url=https://x.dev/a --json '{}'").unwrap(),
|
||||
"PUT https://x.dev/a\nContent-Type: application/json\nAccept: application/json\n\n{}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
from_curl("curl https://x.dev/up -F name=doc -F file=@/tmp/a.txt").unwrap(),
|
||||
"POST https://x.dev/up\nContent-Type: multipart/form-data; boundary=WebAppBoundary\n\n--WebAppBoundary\nContent-Disposition: form-data; name=\"name\"\n\ndoc\n--WebAppBoundary\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\n\n< /tmp/a.txt\n--WebAppBoundary--\n"
|
||||
);
|
||||
// Windows `cmd` continuation.
|
||||
assert_eq!(from_curl("curl ^\n \"https://x.dev\"").unwrap(), "GET https://x.dev\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_text_is_not_a_curl_command() {
|
||||
assert_eq!(from_curl("GET https://x.dev"), None);
|
||||
assert_eq!(from_curl("curl -H 'a: b'"), None, "no URL");
|
||||
assert_eq!(from_curl("curl 'https://x.dev"), None, "unclosed quote");
|
||||
}
|
||||
}
|
||||
@@ -76,16 +76,35 @@ fn parse_env(text: &str) -> IdeResult<EnvMap> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The nearest `name` in `dirs`, decoded like the editor opens it (a BOM, UTF-16, or a
|
||||
/// legacy encoding such as GBK from Notepad's ANSI), so a file that shows fine in the editor
|
||||
/// is never skipped as unreadable.
|
||||
fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult<EnvMap> {
|
||||
for d in dirs {
|
||||
let p = d.join(name);
|
||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||
return parse_env(&text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)));
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let text = ide_vfs::load_text(&p).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)))?;
|
||||
return parse_env(&text.text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)));
|
||||
}
|
||||
Ok(EnvMap::new())
|
||||
}
|
||||
|
||||
/// The public (or private) environment file that applies to `file`: the nearest existing one,
|
||||
/// else a new one next to `file` holding a sample environment to fill in.
|
||||
pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeResult<PathBuf> {
|
||||
let name = if private { PRIVATE_FILE } else { PUBLIC_FILE };
|
||||
if let Some(found) = search_dirs(Some(file), root).into_iter().map(|d| d.join(name)).find(|p| p.is_file()) {
|
||||
return Ok(found);
|
||||
}
|
||||
let dir = file.parent().ok_or_else(|| IdeError::invalid("the request file has no folder"))?;
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, "{\n \"dev\": {\n \"name\": \"value\"\n }\n}\n")
|
||||
.map_err(|e| IdeError::internal(format!("cannot create {}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Environments {
|
||||
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
let dirs = search_dirs(file, root);
|
||||
@@ -266,10 +285,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_env_files_saved_with_a_byte_order_mark() {
|
||||
fn reads_env_files_in_the_encodings_windows_editors_save() {
|
||||
let utf16: Vec<u8> = [0xFF, 0xFE]
|
||||
.into_iter()
|
||||
.chain("{\"dev\":{\"name\":\"测试环境\"}}".encode_utf16().flat_map(u16::to_le_bytes))
|
||||
.collect();
|
||||
// Notepad's "ANSI" on a Chinese system: GBK.
|
||||
let gbk: Vec<u8> =
|
||||
[&b"{\"dev\":{\"name\":\""[..], &[0xB2, 0xE2, 0xCA, 0xD4, 0xBB, 0xB7, 0xBE, 0xB3], b"\"}}"].concat();
|
||||
for bytes in ["\u{feff}{\"dev\":{\"name\":\"测试环境\"}}".as_bytes().to_vec(), utf16, gbk] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), &bytes).unwrap();
|
||||
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
assert_eq!(envs.variables(Some("dev"))["name"], "测试环境");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_file_is_found_up_the_tree_or_created_next_to_the_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), "\u{feff}{\"dev\":{\"host\":\"h\"}}").unwrap();
|
||||
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
let api = tmp.path().join("api");
|
||||
std::fs::create_dir_all(&api).unwrap();
|
||||
let request = api.join("a.http");
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), "{}").unwrap();
|
||||
assert_eq!(environment_file(&request, Some(tmp.path()), false).unwrap(), tmp.path().join(PUBLIC_FILE));
|
||||
let private = environment_file(&request, Some(tmp.path()), true).unwrap();
|
||||
assert_eq!(private, api.join(PRIVATE_FILE));
|
||||
let envs = Environments::load(Some(&request), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"], "the new file is a valid environment file");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! of the backend, with the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
pub mod client;
|
||||
pub mod curl;
|
||||
pub mod env;
|
||||
pub mod exec;
|
||||
pub mod history;
|
||||
@@ -32,13 +33,7 @@ pub struct RunInput<'a> {
|
||||
/// Prepares and sends request `index` of `text`. Preparation errors (bad
|
||||
/// variables, missing includes) fail; transport errors are part of the exchange.
|
||||
pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<HttpExchange> {
|
||||
let parsed = parse::parse(input.text);
|
||||
let raw = parsed
|
||||
.requests
|
||||
.get(input.index as usize)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
|
||||
let envs = Environments::load(input.file, input.root)?;
|
||||
let request = exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))?;
|
||||
let request = prepare(&input)?;
|
||||
let time_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs_f64() * 1000.0)
|
||||
@@ -70,6 +65,22 @@ pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<H
|
||||
})
|
||||
}
|
||||
|
||||
/// Request `index` of `text` with its variables substituted and includes read.
|
||||
pub fn prepare(input: &RunInput<'_>) -> IdeResult<PreparedRequest> {
|
||||
let parsed = parse::parse(input.text);
|
||||
let raw = parsed
|
||||
.requests
|
||||
.get(input.index as usize)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
|
||||
let envs = Environments::load(input.file, input.root)?;
|
||||
exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))
|
||||
}
|
||||
|
||||
/// Convert to cURL and Copy: request `index` of `text`, resolved, as a cURL command.
|
||||
pub fn curl(input: &RunInput<'_>) -> IdeResult<String> {
|
||||
prepare(input).map(|r| curl::to_curl(&r))
|
||||
}
|
||||
|
||||
/// Environment names available to `file`.
|
||||
pub fn environments(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Vec<String>> {
|
||||
Ok(Environments::load(file, root)?.names())
|
||||
|
||||
@@ -319,6 +319,19 @@ impl ProjectIndex {
|
||||
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
|
||||
/// (the project root plus its configured dependency roots): answers bounded by the cap
|
||||
/// never evict matches from a root because another root filled it first.
|
||||
pub fn symbols_named_within(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
|
||||
}
|
||||
|
||||
/// Incrementally re-indexes changed paths (called from the vfs watcher).
|
||||
/// Cheap: the work is queued for the indexing thread.
|
||||
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
|
||||
@@ -526,11 +526,26 @@ pub(crate) fn find_symbols_until(
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
||||
symbols_named_within(data, name, kind, &[], limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
|
||||
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
|
||||
/// entries keeps symbols outside the roots from materialising or filling the cap.
|
||||
pub(crate) fn symbols_named_within(
|
||||
data: &IndexData,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
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 {
|
||||
|
||||
@@ -528,6 +528,27 @@ fn css_selectors_are_found_by_exact_name() {
|
||||
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
|
||||
let f = Fixture::new(&[
|
||||
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
|
||||
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
|
||||
("dep/b.rs", "struct Decoder;\n"),
|
||||
]);
|
||||
let idx = f.open();
|
||||
let own = f.root.join("a");
|
||||
let dep = f.root.join("dep");
|
||||
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
|
||||
// content root b/ — so dep/ is evicted before the caller's root filter runs.
|
||||
let unfiltered = idx.symbols_named("Decoder", None, 3);
|
||||
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
|
||||
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
|
||||
let found: Vec<String> =
|
||||
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
|
||||
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
|
||||
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_roots_and_excludes_change_without_reopening() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
|
||||
|
||||
@@ -10,6 +10,10 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
|
||||
pub const COMPOSE_SCHEMA_URL: &str =
|
||||
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
|
||||
|
||||
mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
|
||||
@@ -41,11 +41,15 @@ pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// `dir` contains `marker`: a relative path, a file name pattern with one
|
||||
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
|
||||
/// must contain (`pyproject.toml#tool.ruff`).
|
||||
/// `*` (`*.csproj`), or a TOML or JSON file name (no pattern) and a dotted table
|
||||
/// it must contain (`pyproject.toml#tool.ruff`, `package.json#eslintConfig`).
|
||||
fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
if let Some((file, table)) = marker.split_once('#') {
|
||||
return has_toml_table(&dir.join(file), table);
|
||||
return if file.ends_with(".json") {
|
||||
has_json_object(&dir.join(file), table)
|
||||
} else {
|
||||
has_toml_table(&dir.join(file), table)
|
||||
};
|
||||
}
|
||||
let Some((prefix, suffix)) = marker.split_once('*') else {
|
||||
return dir.join(marker).exists();
|
||||
@@ -68,6 +72,13 @@ fn has_toml_table(file: &Path, table: &str) -> bool {
|
||||
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
|
||||
}
|
||||
|
||||
/// `file` parses as JSON and has the dotted object `key` (`eslintConfig`).
|
||||
fn has_json_object(file: &Path, key: &str) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(file) else { return false };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
|
||||
key.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(serde_json::Value::is_object)
|
||||
}
|
||||
|
||||
/// `path` is (or, after its creation or removal, was) a root marker of the
|
||||
/// directory above it.
|
||||
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
|
||||
|
||||
@@ -672,7 +672,10 @@ mod tests {
|
||||
};
|
||||
// After the TypeScript fallback (an available server before it would hide
|
||||
// it); before the HTML server (hover, rename and symbols take the first).
|
||||
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
|
||||
assert_eq!(
|
||||
names("typescript"),
|
||||
["vtsls", "typescript-language-server", "angular-language-server", "oxlint", "eslint"]
|
||||
);
|
||||
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
|
||||
for lang in ["typescript", "html"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
@@ -700,6 +703,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_serves_js_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in ["apps/web/src", "apps/admin/src", "apps/api"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("apps/web/eslint.config.mjs"), "export default [];").unwrap();
|
||||
std::fs::write(root.join("apps/admin/package.json"), r#"{"eslintConfig": {"root": true}}"#).unwrap();
|
||||
std::fs::write(root.join("apps/api/package.json"), r#"{"name": "api"}"#).unwrap();
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let eslint = cfg.language_servers.iter().find(|s| s.name == "eslint").unwrap();
|
||||
assert_eq!(eslint.command, "vscode-eslint-language-server", "{lang}");
|
||||
assert!(eslint.require_root_marker && !eslint.fallback, "{lang}");
|
||||
let settings = eslint.settings.as_ref().unwrap();
|
||||
assert_eq!(settings.get("validate"), Some(&"on".into()), "{lang}");
|
||||
// The server reads `experimental.useFlatConfig` and fails every request without the object.
|
||||
assert!(settings.get("experimental").is_some_and(serde_json::Value::is_object), "{lang}");
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &eslint.root_markers);
|
||||
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
|
||||
assert_eq!(served("apps/admin/src/main.ts"), Some(root.join("apps/admin")), "{lang}");
|
||||
assert_eq!(served("apps/api/main.ts"), None, "{lang}: package.json without eslintConfig");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_lsp_serves_php_files_only_below_artisan() {
|
||||
let reg = reg();
|
||||
@@ -709,6 +739,8 @@ mod tests {
|
||||
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
|
||||
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
|
||||
assert_eq!(lsp.command.as_str(), "laravel-lsp");
|
||||
// Its `lsp` command takes no options and speaks over stdio; `--stdio` stops it at startup.
|
||||
assert!(lsp.args.is_empty(), "{:?}", lsp.args);
|
||||
assert!(lsp.require_root_marker && !lsp.fallback);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Docker Compose content detection and `yaml-language-server` schema association
|
||||
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
|
||||
|
||||
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SERVER: &str = "yaml-language-server";
|
||||
|
||||
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
|
||||
|
||||
const SERVICE_KEYS: &[&str] = &[
|
||||
"image",
|
||||
"build",
|
||||
"container_name",
|
||||
"hostname",
|
||||
"domainname",
|
||||
"ports",
|
||||
"expose",
|
||||
"environment",
|
||||
"env_file",
|
||||
"volumes",
|
||||
"volumes_from",
|
||||
"networks",
|
||||
"network_mode",
|
||||
"depends_on",
|
||||
"command",
|
||||
"entrypoint",
|
||||
"restart",
|
||||
"pull_policy",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"develop",
|
||||
"labels",
|
||||
"annotations",
|
||||
"platform",
|
||||
"profiles",
|
||||
"user",
|
||||
"working_dir",
|
||||
"privileged",
|
||||
"read_only",
|
||||
"stdin_open",
|
||||
"tty",
|
||||
"init",
|
||||
"cap_add",
|
||||
"cap_drop",
|
||||
"cgroup",
|
||||
"cgroup_parent",
|
||||
"configs",
|
||||
"secrets",
|
||||
"cpus",
|
||||
"cpu_count",
|
||||
"cpu_percent",
|
||||
"cpu_shares",
|
||||
"cpu_period",
|
||||
"cpu_quota",
|
||||
"cpuset",
|
||||
"devices",
|
||||
"dns",
|
||||
"dns_opt",
|
||||
"dns_search",
|
||||
"extends",
|
||||
"external_links",
|
||||
"extra_hosts",
|
||||
"group_add",
|
||||
"ipc",
|
||||
"isolation",
|
||||
"links",
|
||||
"logging",
|
||||
"mac_address",
|
||||
"mem_limit",
|
||||
"mem_reservation",
|
||||
"oom_kill_disable",
|
||||
"oom_score_adj",
|
||||
"pid",
|
||||
"pids_limit",
|
||||
"post_start",
|
||||
"pre_stop",
|
||||
"runtime",
|
||||
"scale",
|
||||
"security_opt",
|
||||
"shm_size",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"storage_opt",
|
||||
"sysctls",
|
||||
"tmpfs",
|
||||
"ulimits",
|
||||
"userns_mode",
|
||||
"uts",
|
||||
];
|
||||
|
||||
fn parse_key(trimmed: &str) -> Option<&str> {
|
||||
let colon = trimmed.find(':')?;
|
||||
let after = &trimmed[colon + 1..];
|
||||
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let key = trimmed[..colon].trim_end();
|
||||
let key = key
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
|
||||
.unwrap_or(key);
|
||||
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
|
||||
.then_some(key)
|
||||
}
|
||||
|
||||
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
|
||||
pub fn is_named_compose_file(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
|
||||
return false;
|
||||
};
|
||||
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
|
||||
let is_head = |head: &str| {
|
||||
head == "compose"
|
||||
|| head == "docker-compose"
|
||||
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|
||||
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
|
||||
};
|
||||
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
|
||||
}
|
||||
|
||||
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
|
||||
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
|
||||
!is_named_compose_file(path) && is_compose_content(text)
|
||||
}
|
||||
|
||||
/// Whether `text` looks like a Docker Compose YAML configuration.
|
||||
pub fn is_compose_content(text: &str) -> bool {
|
||||
let mut has_services = false;
|
||||
let mut has_compose_top = false;
|
||||
let mut has_api_version = false;
|
||||
let mut has_kind = false;
|
||||
let mut has_on = false;
|
||||
let mut has_jobs = false;
|
||||
let mut in_services = false;
|
||||
let mut service_indent = None;
|
||||
let mut prop_indent = None;
|
||||
let mut has_service_prop = false;
|
||||
|
||||
for raw in text.lines().take(600) {
|
||||
let line = raw.trim_end_matches('\r');
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let lead = line.len() - trimmed.len();
|
||||
if lead == 0 {
|
||||
in_services = false;
|
||||
if let Some(key) = parse_key(trimmed) {
|
||||
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
|
||||
return false;
|
||||
}
|
||||
has_api_version |= key == "apiVersion";
|
||||
has_kind |= key == "kind";
|
||||
has_on |= key == "on";
|
||||
has_jobs |= key == "jobs";
|
||||
if (has_api_version && has_kind) || (has_on && has_jobs) {
|
||||
return false;
|
||||
}
|
||||
if key == "services" {
|
||||
has_services = true;
|
||||
in_services = true;
|
||||
service_indent = None;
|
||||
prop_indent = None;
|
||||
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
|
||||
has_compose_top = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_services && !has_service_prop {
|
||||
let svc_indent = *service_indent.get_or_insert(lead);
|
||||
if lead == svc_indent {
|
||||
prop_indent = None;
|
||||
} else if lead > svc_indent {
|
||||
let p_indent = *prop_indent.get_or_insert(lead);
|
||||
if lead == p_indent
|
||||
&& let Some(prop) = parse_key(trimmed)
|
||||
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
|
||||
{
|
||||
has_service_prop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
has_services && (has_compose_top || has_service_prop)
|
||||
}
|
||||
|
||||
/// Appends open content-detected Compose files to `yaml-language-server`'s
|
||||
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
|
||||
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
|
||||
if cfg.name != SERVER || compose_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
let schemas = settings
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.entry("yaml")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
|
||||
if let Some(schemas) = schemas {
|
||||
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
|
||||
if let Value::String(single) = entry {
|
||||
*entry = json!([single.clone()]);
|
||||
}
|
||||
if let Some(list) = entry.as_array_mut() {
|
||||
for path in compose_files {
|
||||
let p = path.to_string_lossy().replace('\\', "/");
|
||||
let negated = format!("!{p}");
|
||||
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
|
||||
continue;
|
||||
}
|
||||
list.push(Value::String(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
#[test]
|
||||
fn detects_compose_content_and_ignores_other_manifests() {
|
||||
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
|
||||
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
|
||||
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
|
||||
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
|
||||
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
|
||||
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
|
||||
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
|
||||
for named in
|
||||
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
|
||||
{
|
||||
assert!(is_named_compose_file(Path::new(named)), "{named}");
|
||||
}
|
||||
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
|
||||
{
|
||||
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
|
||||
}
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose-dev.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(!is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/custom-stack.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
|
||||
let reg = LanguageRegistry::load(None).unwrap();
|
||||
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
|
||||
let mut cfg = yaml.language_servers[0].clone();
|
||||
let a = PathBuf::from("/work/custom-stack.yml");
|
||||
let b = PathBuf::from("/work/excluded.yml");
|
||||
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!("!/work/excluded.yml"));
|
||||
|
||||
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
|
||||
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(list.contains(&"compose.yml"));
|
||||
assert!(list.contains(&"*-compose.yml"));
|
||||
assert!(list.contains(&"/work/custom-stack.yml"));
|
||||
assert!(!list.contains(&"/work/excluded.yml"));
|
||||
assert!(list.contains(&"!/work/excluded.yml"));
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,16 @@ impl ClientHandler {
|
||||
}
|
||||
};
|
||||
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
|
||||
if self.server.name == "jdtls"
|
||||
&& let Some(body) = crate::jdtls::build_plan_failure(&path, &self.server.root, ¶ms.diagnostics)
|
||||
&& mgr.first_message(&self.server.name, &body)
|
||||
{
|
||||
mgr.sink().emit(ide_core::IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Error,
|
||||
title: "Maven project could not be configured".into(),
|
||||
body: Some(body),
|
||||
});
|
||||
}
|
||||
if !mgr.accepts_injected_diagnostics(&path, params.version) {
|
||||
return;
|
||||
}
|
||||
@@ -303,6 +313,7 @@ impl Handler for ClientHandler {
|
||||
self.progress(&mgr, json!({ "token": ANGULAR_LOADING, "value": { "kind": "end" } }))
|
||||
}
|
||||
"telemetry/event" | "$/logTrace" | "$/cancelRequest" => {}
|
||||
"eslint/status" | "eslint/probeFailed" | "eslint/exitCalled" => {}
|
||||
other => tracing::trace!("[{}] unhandled notification {other}", self.server.name),
|
||||
}
|
||||
}
|
||||
@@ -343,6 +354,10 @@ impl Handler for ClientHandler {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"workspace/inlineValue/refresh" => Ok(Value::Null),
|
||||
// vscode-eslint asks its client before using the project's eslint (approved: 4) and
|
||||
// reports a missing library or configuration; diagnostics already say what's wrong.
|
||||
"eslint/confirmESLintExecution" => Ok(json!(4)),
|
||||
"eslint/noLibrary" | "eslint/noConfig" | "eslint/openDoc" => Ok(json!({})),
|
||||
other => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,17 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath {
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders of `cp` inside the project that don't exist: build outputs deleted outside
|
||||
/// the IDE. Archives and folders outside the project (dependencies) are left out.
|
||||
fn missing_outputs(cp: &JavaClasspath, project_root: &Path) -> Vec<PathBuf> {
|
||||
cp.class_paths
|
||||
.iter()
|
||||
.chain(&cp.module_paths)
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.starts_with(project_root) && p.extension().is_none() && !p.exists())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `vscode.java.startDebugSession` answers the port as a number (or numeric string).
|
||||
pub fn parse_port(v: &Value) -> Option<u16> {
|
||||
v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).and_then(|p| u16::try_from(p).ok())
|
||||
@@ -905,6 +916,43 @@ impl LspManager {
|
||||
};
|
||||
(cp, java_home)
|
||||
};
|
||||
// An output folder deleted outside the IDE (`mvn clean` in a terminal) leaves JDT's
|
||||
// incremental state current: the build compiles nothing and the launch fails with
|
||||
// NoClassDefFoundError. Rebuild fully, once per folder that stays missing.
|
||||
let missing = match (build, &cp) {
|
||||
(Some(_), Some(found)) if !full_build && !build_lock_held => {
|
||||
let (server, _) = self.jdtls(module, cancel).await?;
|
||||
let absent = server.java_absent_outputs.lock();
|
||||
missing_outputs(found, &self.project_root).into_iter().filter(|p| !absent.contains(p)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (build, cp) = if missing.is_empty() {
|
||||
(build, cp)
|
||||
} else {
|
||||
tracing::info!(?missing, "Java output folders are missing; rebuilding the workspace");
|
||||
let rebuilt = self.java_build(module, true, cancel).await?;
|
||||
if rebuilt == JavaBuildStatus::Cancelled {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (server, _) = self.jdtls(module, cancel).await?;
|
||||
let fresh = {
|
||||
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
|
||||
self.java_classpath(module, test, cancel).await
|
||||
};
|
||||
let cp = match fresh {
|
||||
Ok(fresh) => Some(fresh),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("java.project.getClasspaths after rebuild: {e}");
|
||||
cp
|
||||
}
|
||||
};
|
||||
if let Some(cp) = &cp {
|
||||
server.java_absent_outputs.lock().extend(missing_outputs(cp, &self.project_root));
|
||||
}
|
||||
(Some(rebuilt), cp)
|
||||
};
|
||||
let (errors, error_count, scoped) = match (build, build_lock_held) {
|
||||
(Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed), _) | (None, true) => {
|
||||
let scope_module = module.to_path_buf();
|
||||
@@ -1034,6 +1082,29 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// m2e's error on a `pom.xml` whose build plan can't be computed: a build plugin (or one of its
|
||||
/// dependencies) doesn't resolve, e.g. a version a company mirror lacks. The module is then left
|
||||
/// without its Java configuration and navigation finds nothing, with only this marker to tell.
|
||||
pub(crate) fn build_plan_failure(path: &Path, root: &Path, diagnostics: &[lsp_types::Diagnostic]) -> Option<String> {
|
||||
if path.file_name()? != "pom.xml" {
|
||||
return None;
|
||||
}
|
||||
let message = diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.severity == Some(lsp_types::DiagnosticSeverity::ERROR))
|
||||
.find_map(|d| d.message.split_once("Could not calculate build plan:").map(|(_, m)| m))?;
|
||||
const UNRESOLVED: &str = "could not be resolved";
|
||||
let reason = match message.find(UNRESOLVED) {
|
||||
Some(end) => &message[..end + UNRESOLVED.len()],
|
||||
None => message.lines().next().unwrap_or(message),
|
||||
};
|
||||
Some(format!(
|
||||
"{}: {}. Java navigation and completion don't work in this module until Maven resolves it: make it available in your Maven repository or mirror, or set a version the repository has in pom.xml.",
|
||||
path.strip_prefix(root).unwrap_or(path).display(),
|
||||
reason.trim().trim_end_matches(':')
|
||||
))
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// JDT LS aborted its import with `message`: when reserved Windows device names
|
||||
/// below `root` are the cause, say which (once). The walk runs off the RPC thread.
|
||||
@@ -1062,6 +1133,29 @@ mod tests {
|
||||
language.language_servers.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolvable_build_plugin_is_explained() {
|
||||
let diagnostic = |severity, message: &str| lsp_types::Diagnostic {
|
||||
severity: Some(severity),
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let error = diagnostic(
|
||||
lsp_types::DiagnosticSeverity::ERROR,
|
||||
"CoreException: Could not calculate build plan: Plugin org.apache.maven.plugins:maven-compiler-plugin:3.13.0 or one of its dependencies could not be resolved: Failure to find org.apache.maven.plugins:maven-compiler-plugin:jar:3.13.0 in https://nexus.corp/repository/public",
|
||||
);
|
||||
let info = diagnostic(
|
||||
lsp_types::DiagnosticSeverity::INFORMATION,
|
||||
"Plugin execution not covered by lifecycle configuration",
|
||||
);
|
||||
let (root, pom) = (Path::new("/w"), Path::new("/w/app/pom.xml"));
|
||||
let body = build_plan_failure(pom, root, &[info.clone(), error.clone()]).unwrap();
|
||||
assert!(body.starts_with("app/pom.xml: Plugin org.apache.maven.plugins:maven-compiler-plugin:3.13.0 or one of its dependencies could not be resolved. Java navigation"), "{body}");
|
||||
assert!(!body.contains("nexus.corp"));
|
||||
assert_eq!(build_plan_failure(pom, root, &[info]), None);
|
||||
assert_eq!(build_plan_failure(Path::new("/w/app/src/Main.java"), root, &[error]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_settings_preserve_java_defaults_and_library_resolution() {
|
||||
let mut cfg = java_config();
|
||||
@@ -1329,6 +1423,22 @@ mod tests {
|
||||
assert_eq!(java_style.project_root, Some("/p/my demo".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_outputs_are_project_folders_that_no_longer_exist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("demo");
|
||||
let kept = root.join("app/target/classes");
|
||||
std::fs::create_dir_all(&kept).unwrap();
|
||||
let deleted = root.join("core/target/classes");
|
||||
let s = |p: &Path| p.to_string_lossy().into_owned();
|
||||
let cp = JavaClasspath {
|
||||
project_root: None,
|
||||
class_paths: vec![s(&kept), s(&deleted), s(&root.join("lib/absent.jar")), s(&dir.path().join("elsewhere"))],
|
||||
module_paths: vec![s(&root.join("api/target/classes"))],
|
||||
};
|
||||
assert_eq!(missing_outputs(&cp, &root), [deleted, root.join("api/target/classes")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_settings_and_status() {
|
||||
assert_eq!(parse_port(&json!(53122)), Some(53122));
|
||||
|
||||
@@ -10,6 +10,7 @@ mod angular;
|
||||
mod arduino;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod compose;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
@@ -107,6 +108,8 @@ struct DocState {
|
||||
version: i32,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Attached>,
|
||||
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
|
||||
compose_detected: bool,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -1017,6 +1020,7 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
@@ -1025,9 +1029,12 @@ impl LspManager {
|
||||
version: version as i32,
|
||||
text: Arc::new(DocText::new(text)),
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
};
|
||||
{
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
let already_detected =
|
||||
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
|
||||
let params = state.did_open_params();
|
||||
for a in &mut state.servers {
|
||||
if let Some(run) = a.server.running() {
|
||||
@@ -1038,13 +1045,17 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
docs.insert(doc, state);
|
||||
}
|
||||
compose_detected && !already_detected
|
||||
};
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
if s.name == compose::SERVER && compose_added {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
let server = s.clone();
|
||||
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
|
||||
}
|
||||
@@ -1115,7 +1126,18 @@ impl LspManager {
|
||||
tracing::debug!("{}: didChange: {e}", server.name);
|
||||
}
|
||||
}
|
||||
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
|
||||
let now = compose::is_compose_content(text.as_str());
|
||||
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
|
||||
let closed_path = d.path.clone();
|
||||
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(docs);
|
||||
if compose_changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
@@ -1175,7 +1197,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
let (d, compose_removed) = {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.remove(&doc) else { return };
|
||||
let removed =
|
||||
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
|
||||
(d, removed)
|
||||
};
|
||||
if compose_removed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
let Some(run) = a.server.running() else { continue };
|
||||
@@ -3169,9 +3200,20 @@ impl LspManager {
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
if cfg.name == compose::SERVER {
|
||||
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn open_compose_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
/// Restarts running servers whose settings-derived configuration changed
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
|
||||
@@ -120,6 +120,9 @@ pub struct Server {
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// Classpath folders still missing after a full build (a module without output), so a
|
||||
/// launch rebuilds for a missing output folder only once.
|
||||
pub java_absent_outputs: Mutex<std::collections::HashSet<PathBuf>>,
|
||||
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
|
||||
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
@@ -167,6 +170,7 @@ impl Server {
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_absent_outputs: Mutex::new(Default::default()),
|
||||
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
|
||||
@@ -104,6 +104,12 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = client;
|
||||
None
|
||||
}
|
||||
/// What other plugins contributed to `point`, as (plugin id, JSON). Only the
|
||||
/// plugin that declares the point may read it.
|
||||
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
|
||||
let _ = point;
|
||||
Err(HostError::NotFound("extension points are unavailable on this host".into()))
|
||||
}
|
||||
|
||||
/// Registers a project-scoped wrapper after validating its process grant and pin.
|
||||
fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> {
|
||||
@@ -994,6 +1000,10 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
kind: "settings-changed".into(),
|
||||
data: serde_json::to_string(&keys).expect("keys serialize"),
|
||||
}),
|
||||
WorkbenchEvent::ExtensionsChanged(points) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "extensions-changed".into(),
|
||||
data: serde_json::to_string(&points).expect("points serialize"),
|
||||
}),
|
||||
WorkbenchEvent::InputModeChanged(mode) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "input-mode-changed".into(),
|
||||
data: serde_json::to_string(&mode).expect("mode serializes"),
|
||||
|
||||
@@ -169,6 +169,19 @@ pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
|
||||
}
|
||||
|
||||
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
|
||||
/// content is fine, the size cap is the same as [`read_text`]'s.
|
||||
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", real.display()));
|
||||
}
|
||||
if meta.len() > MAX_READ_BYTES {
|
||||
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
|
||||
}
|
||||
std::fs::read(real).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why a read outside the project was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
@@ -528,6 +541,25 @@ mod tests {
|
||||
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The bytes binary files carry survive, where read_text refuses them.
|
||||
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
|
||||
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
|
||||
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
|
||||
assert_eq!(read_bytes(&p).unwrap(), binary);
|
||||
// A directory is not a file; the size cap is read_text's.
|
||||
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
|
||||
let big = tmp.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
|
||||
let err = read_bytes(&big).unwrap_err();
|
||||
assert!(err.contains("larger than"), "{err}");
|
||||
let at_cap = tmp.path().join("at-cap.bin");
|
||||
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
|
||||
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
|
||||
}
|
||||
|
||||
/// The grant a manifest author would write for `dir`: the plain path, no
|
||||
/// `\\?\` prefix.
|
||||
fn grant_for(dir: &Path) -> Capability {
|
||||
|
||||
@@ -110,6 +110,7 @@ pub enum EventKind {
|
||||
RunExited,
|
||||
TerminalExited,
|
||||
ContentTabAction,
|
||||
ExtensionsChanged,
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
FilesChanged,
|
||||
@@ -120,13 +121,14 @@ pub enum EventKind {
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 13] = [
|
||||
pub const ALL: [Self; 14] = [
|
||||
Self::FileClosed,
|
||||
Self::SettingsChanged,
|
||||
Self::InputModeChanged,
|
||||
Self::RunExited,
|
||||
Self::TerminalExited,
|
||||
Self::ContentTabAction,
|
||||
Self::ExtensionsChanged,
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::FilesChanged,
|
||||
@@ -144,6 +146,7 @@ impl EventKind {
|
||||
Self::RunExited => "run-exited",
|
||||
Self::TerminalExited => "terminal-exited",
|
||||
Self::ContentTabAction => "content-tab-action",
|
||||
Self::ExtensionsChanged => "extensions-changed",
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::FilesChanged => "files-changed",
|
||||
@@ -163,7 +166,11 @@ impl EventKind {
|
||||
pub fn capability(self) -> Option<ide_extension::Capability> {
|
||||
match self {
|
||||
Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None,
|
||||
Self::Scheduled
|
||||
| Self::RunExited
|
||||
| Self::TerminalExited
|
||||
| Self::ContentTabAction
|
||||
| Self::ExtensionsChanged => None,
|
||||
_ => Some(ide_extension::Capability::Events),
|
||||
}
|
||||
}
|
||||
@@ -229,6 +236,8 @@ pub enum WorkbenchEvent {
|
||||
ContentTabAction(crate::ContentTabAction),
|
||||
FileClosed(String),
|
||||
SettingsChanged(Vec<String>),
|
||||
/// Ids of extension points the receiving plugin declares whose contributions changed.
|
||||
ExtensionsChanged(Vec<String>),
|
||||
InputModeChanged(InputMode),
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
@@ -247,6 +256,7 @@ impl WorkbenchEvent {
|
||||
Self::ContentTabAction(_) => EventKind::ContentTabAction,
|
||||
Self::FileClosed(_) => EventKind::FileClosed,
|
||||
Self::SettingsChanged(_) => EventKind::SettingsChanged,
|
||||
Self::ExtensionsChanged(_) => EventKind::ExtensionsChanged,
|
||||
Self::InputModeChanged(_) => EventKind::InputModeChanged,
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -101,6 +102,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -138,6 +140,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -696,6 +699,22 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
|
||||
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -1146,6 +1165,11 @@ impl events::Host for StoreData {
|
||||
{
|
||||
return Ok(Err(to_wit(e)));
|
||||
}
|
||||
if kind == EventKind::ExtensionsChanged
|
||||
&& let Err(e) = self.host.require_api(kind.name(), 5)
|
||||
{
|
||||
return Ok(Err(to_wit(e)));
|
||||
}
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -2356,6 +2380,19 @@ impl bindings::openidea::plugin::database::Host for StoreData {
|
||||
Ok(result.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::extensions::Host for StoreData {
|
||||
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
|
||||
Ok(self
|
||||
.host
|
||||
.extension_contributions(&clip(point, 128))
|
||||
.map(|list| {
|
||||
list.into_iter()
|
||||
.map(|(plugin, data)| bindings::openidea::plugin::extensions::Contribution { plugin, data })
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::mcp::Host for StoreData {
|
||||
fn stdio_command(&mut self) -> wasmtime::Result<Option<Vec<String>>> {
|
||||
Ok(self.client().and_then(|client| self.host.mcp_stdio_command(client)))
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
|
||||
eb7d608bf1e7379620ef7d9ce5289dd9b927f137af2aafdd0861a84d60d677a2
|
||||
@@ -104,6 +104,10 @@ title = "reveal"
|
||||
id = "search-text"
|
||||
title = "search-text"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read-bytes"
|
||||
title = "read-bytes"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "problems"
|
||||
title = "problems"
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Plugin for Api14 {
|
||||
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
|
||||
Ok(Some(format!("{result:?}")))
|
||||
}
|
||||
"read-bytes" => Ok(Some(format!("{:?}", ctx.project().unwrap().read_bytes(arg)?))),
|
||||
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
|
||||
"decorations" => {
|
||||
host::set_decorations(
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
|
||||
195cb4025508e7af6e6fb63d81660c4a977327fa3d101a2a77e5f448f43518f8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
|
||||
49491ad2d57592cca2451b0abde5f88a3aedd3f5d36ceaf8259c3254f59c5023
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
|
||||
b24383f6e05ec531ef8db368b79af710433bc3782438f5a9e3578ebb730a1ce2
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
|
||||
ef01f199e668063b2c444e4ccf169d5b5407e567dbe8ff583cfdfc111b2438cf
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
|
||||
a4dbe2a261e00809b64039ce9c89db681b186788db8a92b7456eca84b33c5945
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
|
||||
3c9db6ffeaa9b782970c7a7a132878f862b0b5b18e9d11ad56649ee28317e3ca
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-ext-owner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-ext-owner"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
ac1dceb016b7963f44e876784ea62f776e90dd63fc6f660a5dad3dfbd7433b1c
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "test.ext-owner"
|
||||
name = "Extension point owner"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.5"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-event:extensions-changed"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
|
||||
[[extension-points]]
|
||||
id = "test.ext-owner.items"
|
||||
description = "Things other plugins add"
|
||||
required = ["name"]
|
||||
fields = { name = "string", weight = "integer" }
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "dump"
|
||||
title = "Dump contributions"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read"
|
||||
title = "Read the contributions to any extension point"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "changes"
|
||||
title = "Show the change events received"
|
||||
@@ -0,0 +1,36 @@
|
||||
use openidea_plugin::{Error, Event, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Owner;
|
||||
|
||||
impl Plugin for Owner {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"dump" => {
|
||||
let list = host::extension_contributions("test.ext-owner.items")?;
|
||||
Ok(Some(list.iter().map(|c| format!("{}:{}", c.plugin, c.data)).collect::<Vec<_>>().join("\n")))
|
||||
}
|
||||
"read" => {
|
||||
let list = host::extension_contributions(arg.unwrap_or_default())?;
|
||||
Ok(Some(list.len().to_string()))
|
||||
}
|
||||
"changes" => host::storage::get("changes"),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
|
||||
let mut seen = host::storage::get("changes").unwrap().unwrap_or_default();
|
||||
for event in events {
|
||||
if let Event::Other { kind, data } = event
|
||||
&& kind == "extensions-changed"
|
||||
{
|
||||
seen.push_str(data);
|
||||
seen.push('\n');
|
||||
}
|
||||
}
|
||||
host::storage::set("changes", &seen).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Owner);
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
|
||||
11dc1a74c0650b493ed6ec88b5d15e08421ab08c5f2320099dabd5df1bdc5096
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
|
||||
94c391142af832a67c694968d307217aabd7c139ae29e3299930b56ce644e45b
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
|
||||
e2c1893a160a0f27af7eb9e689d703577335cce32f746713c2435ae70fb9753d
|
||||
Бінарний файл не відображається.
Loaded 100 of 520 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача