Compare commits

...
Author SHA1 Message Date
l0ng-ai de12bf1189 chore(plugins): rebuild components for set-file-note 2026-11-13 14:15:46 +08:00
l0ng-ai 4c71891924 feat(plugins): notes after file names in the project tree (API 1.7)
Tree decorations could only carry a badge of at most two characters and a
tooltip, so a plugin could not label files and folders with a visible
description.

decorations.set-file-note shows one line of muted text (at most 200
characters, optional tooltip) after a file or folder name, as the project
tree already does for a root's VCS branch. It replaces that tree set's
item for the file, shares the tree-set limits, and is cleared with
clear-file. The SDK wraps it as host::set_file_note with a mock.
2026-11-13 14:08:19 +08:00
l0ng-ai 6143011880 fix(http-client): take the server port from the active Spring profiles' files (#3079)
Generated requests read server.port and the context path only from application.properties / application.yml, so a port in application-dev.yml under spring.profiles.active=dev was ignored. The active profiles' files are now read over the base ones, the last profile winning.
2026-11-13 13:55:41 +08:00
l0ng-ai d1872c07ce feat(http-client): request bodies from DTOs in other modules, header completion (#3078)
The @RequestBody type of a Spring mapping was looked up only beside the controller or in its source root, so a DTO in another Maven module produced {}. Types not found there are looked up through the project index in the imported (or same / wildcard-imported) package, and superclass fields are included. Header names and common header values complete in .http request blocks. Requests opened from an endpoint use the module's server address instead of a fixed localhost:8080.
2026-11-13 13:54:32 +08:00
62 changed files with 521 additions and 96 deletions

No files matched your search

+30 -2
View File
@@ -4125,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4143,7 +4148,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4315,10 +4320,33 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+12 -1
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2507,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
@@ -1 +1 @@
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
@@ -1 +1 @@
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
@@ -1 +1 @@
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
@@ -1 +1 @@
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
@@ -1 +1 @@
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
@@ -1 +1 @@
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
@@ -1 +1 @@
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -1 +1 @@
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
@@ -1 +1 @@
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
@@ -1 +1 @@
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
@@ -1 +1 @@
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
@@ -1 +1 @@
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
@@ -1 +1 @@
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
@@ -1 +1 @@
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
@@ -1 +1 @@
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
@@ -1 +1 @@
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
@@ -1 +1 @@
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
@@ -1 +1 @@
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
@@ -1 +1 @@
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
@@ -1 +1 @@
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
@@ -1 +1 @@
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
@@ -1 +1 @@
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
@@ -1 +1 @@
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
@@ -1 +1 @@
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
@@ -1 +1 @@
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
@@ -1 +1 @@
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
@@ -1 +1 @@
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
+5 -2
View File
@@ -598,6 +598,7 @@ then run `openidea-plugin check`.
| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. |
| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. |
| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. |
| `host::set_file_note` | `decorations.set-file-note` | API 1.7: a note after the name in the project tree (one line, at most 200 characters). Requires `project.read`. |
| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. |
| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. |
| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. |
@@ -851,8 +852,10 @@ Decorations are plain text, with fixed styles mapped to theme tokens:
`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`,
`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items
have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`,
where a badge has at most two Unicode characters. Paths must resolve inside an
open project; absolute paths select that project, relative paths use the invoking
where a badge has at most two Unicode characters. A tree item can instead be a note
(`set-file-note`, API 1.7): muted text after the file or folder name, one line of at
most 200 characters, with an optional tooltip; it replaces that set's item for the file.
Paths must resolve inside an open project; absolute paths select that project, relative paths use the invoking
project. Sets are isolated by plugin and project and shared by all windows of
that project. Setting replaces a set's items for one file; clearing removes that
set in all projects. Each plugin has at most 50 editor sets and 50 tree sets,
+1 -1
View File
@@ -1 +1 @@
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
@@ -1 +1 @@
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
+1 -1
View File
@@ -1 +1 @@
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
+1 -1
View File
@@ -1 +1 @@
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
+1 -1
View File
@@ -1 +1 @@
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
+1 -1
View File
@@ -1 +1 @@
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+1 -1
View File
@@ -1 +1 @@
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
+1 -1
View File
@@ -1 +1 @@
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
+1 -1
View File
@@ -1 +1 @@
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
+1 -1
View File
@@ -1 +1 @@
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
+1 -1
View File
@@ -1 +1 @@
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
+1 -1
View File
@@ -1 +1 @@
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
+1 -1
View File
@@ -1 +1 @@
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
+1 -1
View File
@@ -1 +1 @@
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
+1 -1
View File
@@ -1 +1 @@
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
+8
View File
@@ -416,6 +416,9 @@ mod imp {
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
wit::decorations::clear_file(set).map_err(error)
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
wit::decorations::set_file_note(path, set, note, tooltip).map_err(error)
}
pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> {
wit::vcs::log(p, path, limit)
.map(|v| {
@@ -1842,6 +1845,11 @@ pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Resu
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
imp::clear_file_decorations(set)
}
/// Set a note shown after a file's name in the project tree, replacing this tree set's
/// annotation for the file (API 1.7). One line of at most 200 characters. Requires project.read.
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
imp::set_file_note(path, set, note, tooltip)
}
/// Starts an owned run configuration in the invoking window. Its id receives run-exited.
pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> {
+30 -2
View File
@@ -156,6 +156,7 @@ struct State {
restricted: bool,
decorations: BTreeMap<(String, String), Vec<Decoration>>,
file_decorations: BTreeMap<(String, String), FileDecoration>,
file_notes: BTreeMap<(String, String), (String, Option<String>)>,
revealed_ranges: Vec<Range>,
loopback_queries: VecDeque<Option<String>>,
net: Option<NetHandler>,
@@ -300,6 +301,10 @@ impl MockHost {
pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> {
self.state.borrow().file_decorations.clone()
}
/// `(set, path)` → `(note, tooltip)` set with `set_file_note`.
pub fn file_notes(&self) -> BTreeMap<(String, String), (String, Option<String>)> {
self.state.borrow().file_notes.clone()
}
/// Ranges requested to scroll into view.
pub fn revealed_ranges(&self) -> Vec<Range> {
self.state.borrow().revealed_ranges.clone()
@@ -1655,9 +1660,9 @@ pub mod current {
let mut next = s.file_decorations.clone();
next.remove(&(set.into(), path.clone()));
if style(&item.style) {
next.insert((set.into(), path), item.clone());
next.insert((set.into(), path.clone()), item.clone());
}
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1666,6 +1671,7 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1673,6 +1679,28 @@ pub mod current {
with(|s| {
require(s, "project.read")?;
s.file_decorations.retain(|(id, _), _| id != set);
s.file_notes.retain(|(id, _), _| id != set);
Ok(())
})
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
let path = decoration_path(s, path)?;
let key = (set.to_string(), path);
let names: BTreeSet<_> =
s.file_decorations.keys().chain(s.file_notes.keys()).chain([&key]).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
|| note.trim().is_empty()
|| note.chars().count() > 200
|| note.contains(['\n', '\r'])
{
return Err(Error::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
s.file_decorations.remove(&key);
s.file_notes.insert(key, (note.into(), tooltip.map(Into::into)));
Ok(())
})
}
@@ -67,6 +67,14 @@ fn decorations_replace_clear_validate_styles_badges_paths_and_limits() {
host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap();
host::clear_file_decorations("test").unwrap();
assert!(mock.file_decorations().is_empty());
host::set_file_decoration("a.txt", "notes", &file("A")).unwrap();
host::set_file_note("a.txt", "notes", "entry point", Some("why")).unwrap();
assert!(mock.file_decorations().is_empty(), "a note replaces the set's annotation");
assert_eq!(mock.file_notes().values().next(), Some(&("entry point".to_string(), Some("why".to_string()))));
assert!(host::set_file_note("a.txt", "notes", "two\nlines", None).is_err());
assert!(host::set_file_note("a.txt", "notes", &"x".repeat(201), None).is_err());
host::clear_file_decorations("notes").unwrap();
assert!(mock.file_notes().is_empty());
for i in 0..50 {
host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap();
}
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
+2
View File
@@ -4932,6 +4932,8 @@ export type FileDecoration = {
badge: string | null,
style: string,
tooltip: string | null,
/** Text shown after the name (API 1.7 `set-file-note`). */
note?: string | null,
};
export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[];
+13 -4
View File
@@ -10,6 +10,9 @@ import { gutterIconMarkup, showContextMenu } from "@/ui";
import { emptyGutterSpacer, gutterSpacer } from "@/editor/api";
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { appendGeneratedRequests } from "./generatedRequests";
import { headerCompletion } from "./headers";
import { sourceReader } from "./spring/editor";
import { findHost } from "./spring/request";
import { METHODS } from "./language";
import styles from "./HttpClient.module.css";
@@ -17,13 +20,15 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
export function renderEndpoint(e: EndpointEntry): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
export function renderEndpoint(e: EndpointEntry, host = "http://localhost:8080"): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} ${host}${endpointUrl(e.url)}\n`;
}
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
await appendGeneratedRequests([renderEndpoint(endpoint)]);
// The server address of the endpoint's module (`server.port`, context path), as for Spring mappings.
const host = await findHost(endpoint.path, useProject.getState().project?.root ?? null, sourceReader);
await appendGeneratedRequests([renderEndpoint(endpoint, host)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
@@ -94,7 +99,11 @@ class EndpointMarker extends GutterMarker {
const endpointSpacer = gutterSpacer(styles.runMarker ?? "");
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.language === "http")
return EditorState.languageData.of(() => [
{ openideaCompletion: endpointCompletion },
{ openideaCompletion: headerCompletion },
]);
if (ctx.virtual) return [];
return [
marks,
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { headerTarget } from "./headers";
const at = (text: string) => headerTarget(text.replace("|", ""), text.indexOf("|"));
describe("header completion", () => {
it("completes header names on the lines after the request line", () => {
expect(at("POST http://localhost:8080/users\nCon|")).toEqual({ kind: "name", from: 33, prefix: "Con" });
expect(at("### Create\n# @name create\nPOST {{host}}/users\nAccept: */*\n|")).toMatchObject({
kind: "name",
prefix: "",
});
expect(at("http://localhost/a\nX-Re|")).toMatchObject({ kind: "name", prefix: "X-Re" });
});
it("completes the values of a header", () => {
expect(at("POST http://x/a\nContent-Type: app|")).toEqual({
kind: "value",
name: "Content-Type",
from: 30,
prefix: "app",
});
expect(at("GET http://x/a\nAuthorization:|")).toMatchObject({ kind: "value", name: "Authorization", prefix: "" });
});
it("stays out of request lines, bodies, comments and blocks without a request", () => {
expect(at("POS|")).toBeNull();
expect(at('POST http://x/a\nContent-Type: application/json\n\n{"a|')).toBeNull();
expect(at("POST http://x/a\n# Acc|")).toBeNull();
expect(at("@host = http://x\nAcc|")).toBeNull();
expect(at("POST http://x/a\n\n###\nAcc|")).toBeNull();
});
});
+131
View File
@@ -0,0 +1,131 @@
/** Header name and value completion in `.http` request blocks (the lines after the request line). */
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { METHODS } from "./language";
const REQUEST_LINE = new RegExp(`^\\s*(?:(?:${METHODS.join("|")})\\s+\\S|https?://|\\{\\{)`);
const COMMENT = /^\s*(?:#|\/\/)/;
const HEADERS = [
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Authorization",
"Cache-Control",
"Connection",
"Content-Disposition",
"Content-Encoding",
"Content-Language",
"Content-Length",
"Content-Type",
"Cookie",
"Date",
"Expect",
"Forwarded",
"From",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
"Max-Forwards",
"Origin",
"Pragma",
"Proxy-Authorization",
"Range",
"Referer",
"TE",
"Upgrade",
"User-Agent",
"Via",
"X-Correlation-ID",
"X-CSRF-Token",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Request-ID",
"X-Requested-With",
];
const MEDIA_TYPES = [
"application/json",
"application/xml",
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
"text/html",
"text/xml",
"text/csv",
"application/octet-stream",
"application/graphql",
"application/x-ndjson",
"application/problem+json",
"*/*",
];
const VALUES: Record<string, string[]> = {
accept: MEDIA_TYPES,
"content-type": MEDIA_TYPES,
"accept-encoding": ["gzip, deflate, br", "gzip", "deflate", "br", "identity"],
"content-encoding": ["gzip", "deflate", "br"],
"accept-charset": ["utf-8", "iso-8859-1"],
"accept-language": ["en-US", "en", "zh-CN", "zh"],
authorization: ["Bearer ", "Basic "],
"proxy-authorization": ["Bearer ", "Basic "],
"cache-control": ["no-cache", "no-store", "max-age=0", "must-revalidate"],
pragma: ["no-cache"],
connection: ["keep-alive", "close", "Upgrade"],
upgrade: ["websocket", "h2c"],
expect: ["100-continue"],
"x-requested-with": ["XMLHttpRequest"],
"content-disposition": ["attachment", "inline", 'form-data; name=""'],
"access-control-request-method": METHODS,
};
export type HeaderTarget =
| { kind: "name"; from: number; prefix: string }
| { kind: "value"; name: string; from: number; prefix: string };
/** What is being typed at `pos`: a header name or a header's value, or null outside a request's headers. */
export function headerTarget(text: string, pos: number): HeaderTarget | null {
const before = text.slice(0, pos);
const lineStart = before.lastIndexOf("\n") + 1;
const line = before.slice(lineStart);
if (COMMENT.test(line)) return null;
const lines = (
before
.slice(0, lineStart)
.split(/^\s*###.*$/m)
.at(-1) ?? ""
)
.split("\n")
.slice(0, -1);
let request = false;
for (const l of lines) {
if (COMMENT.test(l)) continue;
if (!request) request = REQUEST_LINE.test(l);
// A blank line ends the headers; the body follows.
else if (!l.trim()) return null;
}
if (!request) return null;
const colon = line.indexOf(":");
if (colon < 0) return /^[\w-]*$/.test(line) ? { kind: "name", from: lineStart, prefix: line } : null;
const prefix = line.slice(colon + 1).replace(/^\s+/, "");
return { kind: "value", name: line.slice(0, colon).trim(), from: pos - prefix.length, prefix };
}
export function headerCompletion(context: CompletionContext): CompletionResult | null {
const target = headerTarget(context.state.doc.toString(), context.pos);
if (!target) return null;
if (target.kind === "name") {
if (!target.prefix && !context.explicit) return null;
const options: Completion[] = HEADERS.map((name) => ({ label: name, type: "property", apply: `${name}: ` }));
return { from: target.from, options, validFor: /^[\w-]*$/ };
}
const values = VALUES[target.name.toLowerCase()];
if (!values) return null;
return { from: target.from, options: values.map((v) => ({ label: v, type: "constant" })) };
}
+4 -3
View File
@@ -21,7 +21,7 @@ export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
const reader: SourceReader = {
export const sourceReader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
@@ -29,6 +29,7 @@ const reader: SourceReader = {
return null;
}
},
filesNamed: (name) => call(commands.findFilesNamed(name)),
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
@@ -43,12 +44,12 @@ export async function generateRequests(sourcePath: string, text: string, targets
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, reader),
findHost(sourcePath, root, sourceReader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
reader,
sourceReader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
+100 -37
View File
@@ -115,9 +115,18 @@ export function skeleton(type: string, lookup: (name: string) => ClassDecl | nul
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
// Superclass properties first, as Jackson writes them.
const chain = [cls];
for (let c = cls; c.superclass && chain.length <= MAX_SUPERCLASSES;) {
const parent = lookup(bareType(c.superclass).replace(/<.*$/, ""));
if (!parent || chain.includes(parent)) break;
chain.unshift((c = parent));
}
const obj: { [k: string]: Json } = {};
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
for (const c of chain) {
for (const p of c.ctorParams) if (!(p.name in obj)) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of c.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
}
return obj;
}
@@ -175,34 +184,52 @@ export function appendRequests(existing: string, blocks: string[]): { insert: st
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
/** The project's files called `name` (from the index). */
filesNamed?(name: string): Promise<string[]>;
}
/** How many superclasses of a body type are followed for their fields. */
const MAX_SUPERCLASSES = 5;
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null): string[] {
/** The Spring Boot configuration files (of `profile`) for `sourcePath` (its module's `src/main/resources`), most specific first. */
export function configCandidates(sourcePath: string, projectRoot: string | null, profile?: string): string[] {
const norm = sourcePath.replace(/\\/g, "/");
const at = norm.lastIndexOf("/src/main/");
const roots = at >= 0 ? [norm.slice(0, at)] : [];
if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, ""));
return roots.flatMap((r) =>
["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`),
["properties", "yml", "yaml"].map((x) => `${r}/src/main/resources/application${profile ? `-${profile}` : ""}.${x}`),
);
}
export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise<string> {
const configs: Map<string, string>[] = [];
for (const path of configCandidates(sourcePath, projectRoot)) {
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return hostFromConfig(configs);
const read = async (paths: string[]) => {
const configs: Map<string, string>[] = [];
for (const path of paths) {
const text = await fs.read(path).catch(() => null);
if (text !== null) configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text));
}
return configs;
};
const base = await read(configCandidates(sourcePath, projectRoot));
// `spring.profiles.active=dev`: `application-dev.*` overrides the base files (the last profile wins).
const active = base.map((c) => resolved(c.get("spring.profiles.active"))).find(Boolean);
const profiles = (active ?? "")
.split(",")
.map((p) => p.trim())
.filter((p) => /^[\w.-]+$/.test(p))
.reverse();
const profiled = await read(profiles.flatMap((p) => configCandidates(sourcePath, projectRoot, p)));
return hostFromConfig([...profiled, ...base]);
}
/**
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
* Body types of `endpoints`: classes of the controller's file, then `<Type>.java|.kt` beside
* it or where its import points (same source root), else the project file of that name in the
* imported (or same / wildcard-imported) package, e.g. a DTO in another Maven module. Their
* superclasses are loaded too.
*/
export async function bodyLookup(
sourcePath: string,
@@ -211,42 +238,78 @@ export async function bodyLookup(
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
/** Where each class was read: names in its declarations resolve from that file. */
const origins = new Map<string, Origin>();
const add = (o: Origin) => {
for (const c of allClasses(o.file))
if (!known.has(c.name)) {
known.set(c.name, c);
origins.set(c.name, o);
}
};
add(file);
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
add(origin);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const readAt = async (path: string) => {
const text = await fs.read(path).catch(() => null);
if (text === null) return null;
const loaded = { path, file: parseSource(text) };
add(loaded);
return loaded;
};
/** Loads `name` as seen from `from` (its imports, then its package). */
const load = async (name: string, from: Origin) => {
if (known.has(name)) return origins.get(name) ?? null;
if (!/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
const packages = imported
? [imported.split(".").slice(0, -1).join(".")]
: [from.file.packageName, ...from.file.imports.filter((i) => i.endsWith(".*")).map((i) => i.slice(0, -2))];
const root = sourceRoot(from.path, from.file);
const dirs = new Set([...packages.map((p) => (p ? `${root}/${p.replace(/\./g, "/")}` : root)), dirname(from.path)]);
for (const d of dirs)
for (const x of ext) {
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
const loaded = await readAt(`${d}/${name}${x}`);
if (loaded) return loaded;
}
// Another source root or module: the project's files of that name, in one of those packages.
if (!fs.filesNamed) return null;
const suffixes = packages.map((p) => `/${p ? `${p.replace(/\./g, "/")}/` : ""}${name}`);
for (const x of ext) {
const paths = (await fs.filesNamed(`${name}${x}`).catch(() => [])).map((p) => p.replace(/\\/g, "/"));
const path = paths.find((p) => suffixes.some((s) => p.endsWith(`${s}${x}`)));
const loaded = path ? await readAt(path) : null;
if (loaded) return loaded;
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
/** `name`, its superclasses, and the property types of each, one level down. */
const loadWithProperties = async (name: string, from: Origin, depth: number) => {
const seen = new Set<string>();
let [current, at]: [string | undefined, Origin | null] = [name, from];
while (current && at && !seen.has(current) && seen.size < MAX_SUPERCLASSES) {
seen.add(current);
const loaded = await load(current, at);
const c = known.get(current);
if (!c || !loaded) break;
if (depth > 0)
for (const p of [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)])
await loadWithProperties(typeName(p.type), loaded, depth - 1);
[current, at] = [c.superclass && typeName(c.superclass), loaded];
}
};
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
for (const n of names) await loadWithProperties(n, origin, 1);
return (name) => known.get(name) ?? null;
}
interface Origin {
path: string;
file: SourceFile;
}
+28
View File
@@ -65,6 +65,8 @@ export interface ClassDecl {
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
/** The class it extends (`extends Base<T>` / Kotlin `: Base()`), as written. */
superclass?: string;
}
export interface SourceFile {
@@ -220,6 +222,26 @@ class Reader {
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** A type name at `i` (`a.b.Base<T>`): its text and the index after it. */
typeName(i: number, to: number): { name: string; end: number } | null {
let j = i;
while (j < to && this.toks[j]?.kind === "id" && (j === i || this.is(j - 1, "."))) {
j++;
if (!this.is(j, ".") || this.toks[j + 1]?.kind !== "id") break;
j++;
}
if (j === i) return null;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
return { name: joined(this.toks.slice(i, j)), end: j };
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
@@ -415,6 +437,12 @@ class Reader {
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
// Java `extends Base`; Kotlin `: Base(…)` (a supertype called with arguments is the class).
const javaSuper = this.toks[j]!.kind === "id" && this.toks[j]!.text === "extends";
if (!cls.superclass && (javaSuper || this.is(j, ":") || this.is(j, ","))) {
const type = this.typeName(j + 1, to);
if (type && (javaSuper || this.is(type.end, "("))) cls.superclass = type.name;
}
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
@@ -252,6 +252,19 @@ describe("server address", () => {
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001");
expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000");
});
it("reads the active profiles' files over the base ones", async () => {
const files: Record<string, string> = {
"/ws/api/src/main/resources/application.yml":
"server:\n port: 8001\n servlet:\n context-path: /api\nspring:\n profiles:\n active: ${PROFILE:local}, dev\n",
"/ws/api/src/main/resources/application-local.properties": "server.port=7000\n",
"/ws/api/src/main/resources/application-dev.yml": "server:\n port: 9100\n",
};
const fs: SourceReader = { read: async (p) => files[p] ?? null };
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:9100/api");
delete files["/ws/api/src/main/resources/application-dev.yml"];
expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:7000/api");
});
});
describe("body types from other files", () => {
@@ -271,6 +284,41 @@ describe("body types from other files", () => {
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
it("finds a DTO in another module through the index, with its superclass fields", async () => {
const files: Record<string, string> = {
"/ws/common/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\nimport com.example.base.BaseEntity;\n@Data\npublic class CreateUser extends BaseEntity<Long> implements Serializable {\n private String name;\n private Address address;\n}\n",
"/ws/common/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic class Address { private String city; }\n",
"/ws/base/src/main/java/com/example/base/BaseEntity.java":
"package com.example.base;\npublic abstract class BaseEntity<ID> { private ID id; private LocalDateTime createdAt; }\n",
// Same name, other package: not the imported class.
"/ws/legacy/src/main/java/com/legacy/CreateUser.java":
"package com.legacy;\npublic class CreateUser { int old; }\n",
};
const fs: SourceReader = {
read: async (p) => files[p] ?? null,
filesNamed: async (name) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
};
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup(
"/ws/web/src/main/java/com/example/demo/web/UserController.java",
parseSource(JAVA),
eps,
fs,
);
expect(skeleton("CreateUser", lookup)).toEqual({ id: {}, createdAt: "", name: "", address: { city: "" } });
});
it("reads Java and Kotlin superclasses", () => {
const java = parseSource("class A extends com.x.Base<T> implements I { }\ninterface J extends K {}");
expect(java.classes.map((c) => c.superclass)).toEqual(["com.x.Base<T>", "K"]);
const kotlin = parseSource(
"class A(val a: Int) : Base(a), Serializable\nclass B : Serializable, Other()\nclass C : I",
);
expect(kotlin.classes.map((c) => c.superclass)).toEqual(["Base", "Other", undefined]);
});
});
describe("appending to the request file", () => {
@@ -66,6 +66,19 @@ it("filters project roots and unknown styles and replaces tree badges", () => {
expect(getFileDecoration("/p/a.txt")?.badge).toBeUndefined();
});
it("shows a tree note after the name without colouring the file", () => {
const note = {
...set(),
tree: true,
items: [],
file: { style: "", badge: null, tooltip: "why", note: "entry point" },
};
setPluginDecorations("test", [note]);
expect(getFileDecoration("/p/a.txt")).toMatchObject({ suffix: [{ text: "entry point" }], tooltip: "why" });
expect(getFileDecoration("/p/a.txt")?.color).toBeUndefined();
setPluginDecorations("test", []);
});
it("selects UTF-16 ranges and reveals without changing text", () => {
handleRuntimeEvent({ type: "editorRanges", path: ctx.path, ranges: [range], reveal: false });
expect(editor.view.state.selection.main.from).toBe(2);
+4 -2
View File
@@ -32,13 +32,15 @@ function currentSets(sets: DecorationSet[]) {
function updateTree(plugin: string, sets: DecorationSet[]) {
const items = new Map<string, FileDecoration>();
for (const s of currentSets(sets))
if (s.tree && s.file && Object.hasOwn(colors, s.file.style)) {
if (s.tree && s.file && (Object.hasOwn(colors, s.file.style) || s.file.note)) {
items.set(s.path, {
color: colors[s.file.style],
color: Object.hasOwn(colors, s.file.style) ? colors[s.file.style] : undefined,
badge: s.file.badge ?? undefined,
tooltip: s.file.tooltip ?? undefined,
faded: s.file.style === "muted",
error: s.file.style === "error",
// A note: muted text after the name, like the VCS branch of a root.
suffix: s.file.note ? [{ text: s.file.note }] : undefined,
});
}
setFileDecorations(`plugin:${plugin}`, items);
+3
View File
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}