mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ae3ca91d | ||
|
|
11b42041d3 | ||
|
|
4c811ba4f5 | ||
|
|
573f98627e | ||
|
|
c942b87abb | ||
|
|
cdecb5916c | ||
|
|
835c44615b | ||
|
|
df79c80aa8 | ||
|
|
73df48db7c |
No files matched your search
@@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
|
||||
out
|
||||
}
|
||||
|
||||
/// The repository directory of the `/`-separated `coordinates` (a group id, or
|
||||
/// a group and an artifact id), each segment validated against the layout's
|
||||
/// alphabet so POM text cannot walk outside `repo`.
|
||||
/// The repository directory of `coordinates` (a group id, or a group and an artifact
|
||||
/// id): the group's dots are directories, an artifact id is one directory whatever dots
|
||||
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
|
||||
/// against the layout's alphabet so POM text cannot walk outside `repo`.
|
||||
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
|
||||
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
|
||||
let (group, artifact) = coordinates.split_first()?;
|
||||
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
|
||||
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
|
||||
}
|
||||
|
||||
@@ -460,6 +462,7 @@ mod tests {
|
||||
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
|
||||
"org/example/lib/2.0/lib-2.0.pom",
|
||||
"net/nu/netty/4.1.100/netty-4.1.100.jar",
|
||||
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
|
||||
// Failed downloads (a version typed while editing): tracking files only, not installed.
|
||||
"org/example/lib/2/lib-2.jar.lastUpdated",
|
||||
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
|
||||
@@ -570,9 +573,10 @@ mod tests {
|
||||
};
|
||||
// Only directories holding artifacts are group ids: `net` and `org`
|
||||
// alone never receive an install, their deeper levels do.
|
||||
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
|
||||
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
|
||||
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
|
||||
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
|
||||
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
|
||||
assert_eq!(options("org"), org);
|
||||
assert_eq!(options("org."), org);
|
||||
assert_eq!(options("org.spr"), Vec::<String>::new());
|
||||
assert_eq!(options("net."), ["net.nu"]);
|
||||
}
|
||||
@@ -623,6 +627,19 @@ mod tests {
|
||||
};
|
||||
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
|
||||
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
|
||||
// An artifact id with dots is one directory.
|
||||
let dotted = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::Version,
|
||||
group: Some("org.eclipse.platform".into()),
|
||||
artifact: Some("org.eclipse.core.runtime".into()),
|
||||
},
|
||||
);
|
||||
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,13 +61,16 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
|
||||
host.require_trust(window)?;
|
||||
let current = host.project(window).ok().map(|p| p.root.clone());
|
||||
let config = host.config_dir.clone();
|
||||
// Probing runs the project's interpreter (and its settings may name any program): only
|
||||
// projects trusted in their own right, not ones last opened in Restricted Mode.
|
||||
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|p| p.path.into_path_buf())
|
||||
.filter(|root| current.as_deref() != Some(root.as_path()))
|
||||
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
|
||||
.collect();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|p| p.path.into_path_buf())
|
||||
.filter(|root| current.as_deref() != Some(root.as_path()))
|
||||
.collect();
|
||||
// Each project resolves its configured interpreter, else its own virtualenv. The
|
||||
// probes run `python`, so keep them off the async runtime and bound how many run at
|
||||
// once: the recent list can hold ~30 projects, and each probe starts a process.
|
||||
@@ -521,6 +524,10 @@ mod tests {
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// A project last opened in Restricted Mode: its interpreter is never run.
|
||||
host.trust.decide(&other, false, false).unwrap();
|
||||
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
|
||||
host.trust.decide(&other, true, false).unwrap();
|
||||
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
|
||||
assert_eq!(list.len(), 1, "{list:?}");
|
||||
assert_eq!(&*list[0].project, other.as_path());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -239,6 +239,9 @@ impl LspManager {
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<DocumentCheck> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
// The text may have changed before this call synced it (the file watcher reloaded it):
|
||||
// only diagnostics published after the servers got the current text are fresh.
|
||||
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let ready = tokio::select! {
|
||||
|
||||
@@ -120,6 +120,8 @@ struct DocState {
|
||||
compose_detected: bool,
|
||||
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
|
||||
notebook: Option<PathBuf>,
|
||||
/// When the servers last got new text (open or change): diagnostics published before are stale.
|
||||
changed: std::time::Instant,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -158,6 +160,8 @@ struct DocSnap {
|
||||
language: LanguageId,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Arc<Server>>,
|
||||
/// [`DocState::changed`].
|
||||
changed: std::time::Instant,
|
||||
}
|
||||
|
||||
impl DocSnap {
|
||||
@@ -1170,6 +1174,7 @@ impl LspManager {
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
notebook: None,
|
||||
changed: std::time::Instant::now(),
|
||||
};
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
@@ -1217,6 +1222,7 @@ impl LspManager {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.get_mut(&doc) else { return };
|
||||
d.version = version as i32;
|
||||
d.changed = std::time::Instant::now();
|
||||
|
||||
// Servers that have this document open on their current instance.
|
||||
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
|
||||
@@ -1392,6 +1398,7 @@ impl LspManager {
|
||||
language: d.language.clone(),
|
||||
text: d.text.clone(),
|
||||
servers,
|
||||
changed: d.changed,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ impl LspManager {
|
||||
{
|
||||
if d.text.as_str() != cell.source {
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
d.changed = std::time::Instant::now();
|
||||
}
|
||||
d.version += 1;
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
@@ -234,6 +235,7 @@ impl LspManager {
|
||||
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected: false,
|
||||
notebook: Some(path.to_owned()),
|
||||
changed: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
@@ -259,6 +261,7 @@ impl LspManager {
|
||||
}
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
d.version += 1;
|
||||
d.changed = std::time::Instant::now();
|
||||
content.push(json!({
|
||||
"document": { "uri": d.uri, "version": d.version },
|
||||
"changes": [{ "text": cell.source }],
|
||||
|
||||
@@ -2204,6 +2204,12 @@ async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
|
||||
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
|
||||
assert_eq!(stale.diagnostics.len(), 1);
|
||||
|
||||
// The text changed before the call (the file watcher reloaded it), so the caller saw no
|
||||
// change of its own: the old publication is still not fresh.
|
||||
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
|
||||
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
|
||||
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
|
||||
+69
-15
@@ -17,11 +17,18 @@ pub(crate) const EXTENSION: &str = "vue";
|
||||
const CLI_SERVICE: &str = "@vue/cli-service";
|
||||
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
|
||||
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
|
||||
/// the configuration itself prints.
|
||||
const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
|
||||
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]` (an object, an
|
||||
/// ES module's default, a function of the environment, or a list of configurations), after
|
||||
/// whatever the configuration itself prints.
|
||||
const PRINT_ALIASES: &str = "let c = require(process.argv[1]); if (c && c.__esModule) c = c.default; \
|
||||
if (typeof c === 'function') c = c({}, { mode: 'development' }); if (Array.isArray(c)) c = c[0]; \
|
||||
const a = (c && c.resolve && c.resolve.alias) || {}; \
|
||||
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
|
||||
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
|
||||
/// Webpack configurations of Vue projects without Vue CLI: the project's own, and the
|
||||
/// `vue init webpack` (Vue CLI 2) template's.
|
||||
const WEBPACK_CONFIGS: &[&str] =
|
||||
&["webpack.config.js", "webpack.config.cjs", "build/webpack.base.conf.js", "build/webpack.dev.conf.js"];
|
||||
|
||||
type Paths = BTreeMap<String, Vec<String>>;
|
||||
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
|
||||
@@ -42,24 +49,33 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
|
||||
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` →
|
||||
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
|
||||
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
|
||||
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
|
||||
/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
|
||||
/// is trusted: language servers only run then); else the alias Vue CLI always adds.
|
||||
/// Empty for other projects.
|
||||
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the webpack
|
||||
/// configuration (the project is trusted: language servers only run then): the one
|
||||
/// `@vue/cli-service` resolves with the project's `vue.config.js`, else the project's own
|
||||
/// (`webpack.config.js`, Vue CLI 2's `build/webpack.base.conf.js`). Without one (Vite, or
|
||||
/// no Node), the `@` → `src` alias every Vue template sets up. Empty for other projects.
|
||||
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
|
||||
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
|
||||
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
|
||||
let dependencies = package_dependencies(&manifest);
|
||||
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
|
||||
if !cli && !dependencies.iter().any(|d| d == "vue") {
|
||||
return Paths::new();
|
||||
}
|
||||
let config = root
|
||||
.ancestors()
|
||||
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
|
||||
.find(|f| f.is_file());
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
|
||||
let config = if cli {
|
||||
root.ancestors()
|
||||
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
|
||||
.find(|f| f.is_file())
|
||||
} else {
|
||||
WEBPACK_CONFIGS.iter().map(|f| root.join(f)).find(|f| f.is_file())
|
||||
};
|
||||
let mut inputs: Vec<PathBuf> = ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"]
|
||||
.iter()
|
||||
.chain(WEBPACK_CONFIGS)
|
||||
.map(|f| root.join(f))
|
||||
.collect();
|
||||
inputs.extend(config.iter().cloned());
|
||||
inputs.extend(node.map(Path::to_path_buf));
|
||||
let now = stamp(&inputs);
|
||||
@@ -71,6 +87,7 @@ pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
|
||||
let aliases = config
|
||||
.zip(node)
|
||||
.and_then(|(config, node)| webpack_aliases(root, &config, node))
|
||||
.filter(|aliases| !aliases.is_empty())
|
||||
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
|
||||
let paths = alias_paths(&aliases);
|
||||
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
|
||||
@@ -181,6 +198,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_vue_projects_get_their_webpack_aliases_else_the_at_alias() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// As `__dirname` sees it (macOS: /private/var).
|
||||
let t = &dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(t.join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#).unwrap();
|
||||
std::fs::create_dir_all(t.join("src/views")).unwrap();
|
||||
assert!(cli_paths(t, None).is_empty(), "not a Vue project");
|
||||
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
|
||||
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
|
||||
let (src, views) = (slash(t.join("src")), slash(t.join("src/views")));
|
||||
let at = Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]);
|
||||
assert_eq!(cli_paths(t, None), at, "Vite or no Node: the template alias");
|
||||
let Some(node) = crate::exec::which("node") else { return };
|
||||
// `vue init webpack`: a function of the environment is called, its aliases taken.
|
||||
std::fs::create_dir_all(t.join("build")).unwrap();
|
||||
std::fs::write(
|
||||
t.join("build/webpack.base.conf.js"),
|
||||
r#"const path = require('path');
|
||||
const resolve = (dir) => path.join(__dirname, '..', dir);
|
||||
module.exports = (env) => ({ resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': resolve('src'), views: resolve('src/views') } } });"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cli_paths(t, Some(&node)),
|
||||
Paths::from([
|
||||
("@".into(), vec![src.clone()]),
|
||||
("@/*".into(), vec![format!("{src}/*")]),
|
||||
("views".into(), vec![views.clone()]),
|
||||
("views/*".into(), vec![format!("{views}/*")]),
|
||||
])
|
||||
);
|
||||
// A configuration that can't be loaded (dependencies not installed): the template alias.
|
||||
std::fs::write(t.join("build/webpack.base.conf.js"), "require('not-installed-plugin');").unwrap();
|
||||
assert_eq!(cli_paths(t, Some(&node)), at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
|
||||
let Some(node) = crate::exec::which("node") else { return };
|
||||
|
||||
@@ -61,8 +61,9 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option<String> {
|
||||
|
||||
/// The published version metadata of one artifact on Central.
|
||||
pub fn metadata_url(group: &str, artifact: &str) -> Option<String> {
|
||||
(part(group, 200) && part(artifact, 100)).then(|| {
|
||||
let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().join("/");
|
||||
// The group's dots are directories; an artifact id is one directory, dots and all.
|
||||
(part(group, 200) && part(artifact, 100) && artifact != "." && artifact != "..").then(|| {
|
||||
let path = group.split('.').chain([artifact]).collect::<Vec<_>>().join("/");
|
||||
format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml")
|
||||
})
|
||||
}
|
||||
@@ -146,6 +147,11 @@ mod tests {
|
||||
metadata_url("org.apache.maven", "maven-core").as_deref(),
|
||||
Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml")
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_url("org.eclipse.platform", "org.eclipse.core.runtime").as_deref(),
|
||||
Some("https://repo1.maven.org/maven2/org/eclipse/platform/org.eclipse.core.runtime/maven-metadata.xml")
|
||||
);
|
||||
assert_eq!(metadata_url("org.example", ".."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-2
@@ -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
@@ -1 +1 @@
|
||||
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
|
||||
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
|
||||
@@ -1 +1 @@
|
||||
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
|
||||
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
|
||||
+1
-1
@@ -1 +1 @@
|
||||
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
|
||||
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
|
||||
@@ -1 +1 @@
|
||||
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
|
||||
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
|
||||
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
|
||||
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
|
||||
+1
-1
@@ -1 +1 @@
|
||||
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
|
||||
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
|
||||
+1
-1
@@ -1 +1 @@
|
||||
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
|
||||
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
|
||||
@@ -1 +1 @@
|
||||
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
|
||||
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
|
||||
+1
-1
@@ -1 +1 @@
|
||||
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
|
||||
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
|
||||
+1
-1
@@ -1 +1 @@
|
||||
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
|
||||
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
|
||||
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
|
||||
+1
-1
@@ -1 +1 @@
|
||||
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
|
||||
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
|
||||
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
|
||||
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
//! and is immutable: the revision in the URL changes with the installed
|
||||
//! package. HTML gets a CSP of its own, in case a page navigates its frame to
|
||||
//! one: still sandboxed, still no network.
|
||||
//!
|
||||
//! [`FRAME_HOST_PATH`] is the page that the `html` bodies plugin code renders are
|
||||
//! written into. A `srcdoc` frame would inherit the workbench's CSP, whose
|
||||
//! `script-src` blocks the inline scripts of those bodies; a page of this scheme
|
||||
//! has only the policy of its own response.
|
||||
|
||||
use crate::state::AppState;
|
||||
use tauri::http::{Request, Response, StatusCode, header};
|
||||
@@ -22,6 +27,25 @@ const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \
|
||||
media-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
|
||||
connect-src openidea-plugin: http://openidea-plugin.localhost data: blob:";
|
||||
|
||||
/// The host page of `html` bodies: it tells the workbench it is ready and writes
|
||||
/// the document it gets back (`frameDocument` in src/features/plugins/views/frame.ts).
|
||||
pub const FRAME_HOST_PATH: &str = ".frame";
|
||||
|
||||
/// The policy of rendered `html` bodies (`FRAME_CSP` in frame.ts), sandboxed as their frame is.
|
||||
const FRAME_HOST_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; \
|
||||
style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
|
||||
|
||||
const FRAME_HOST: &str = r#"<!doctype html><meta charset="utf-8"><script>
|
||||
addEventListener("message", (e) => {
|
||||
const d = e.data;
|
||||
if (e.source !== parent || !d || d.tag !== "openidea-view" || typeof d.document !== "string") return;
|
||||
document.open();
|
||||
document.write(d.document);
|
||||
document.close();
|
||||
});
|
||||
parent.postMessage({ tag: "openidea-view", ready: true }, "*");
|
||||
</script>"#;
|
||||
|
||||
/// `window.openidea` (one function expression taking the initial theme), the same
|
||||
/// source the workbench inlines into rendered `html` bodies.
|
||||
const BRIDGE: &str = include_str!("../../src/features/plugins/views/frame-bridge.js");
|
||||
@@ -57,6 +81,15 @@ pub fn handle<R: Runtime>(ctx: UriSchemeContext<'_, R>, request: Request<Vec<u8>
|
||||
|
||||
fn serve(plugins: &ide_host::plugins::PluginHost, path: &str) -> Response<Vec<u8>> {
|
||||
let decoded = percent_encoding::percent_decode_str(path.trim_start_matches('/')).decode_utf8_lossy();
|
||||
if decoded == FRAME_HOST_PATH {
|
||||
return Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CONTENT_SECURITY_POLICY, FRAME_HOST_CSP)
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(FRAME_HOST.as_bytes().to_vec())
|
||||
.unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
let Some((plugin, rest)) = decoded.split_once('/') else { return status(StatusCode::NOT_FOUND) };
|
||||
match plugins.web_file(plugin, rest) {
|
||||
Ok(file) => {
|
||||
@@ -104,4 +137,14 @@ mod tests {
|
||||
let bare = String::from_utf8(with_bridge(b"<p>x</p>")).unwrap();
|
||||
assert!(bare.starts_with("<script>(") && bare.ends_with("<p>x</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_host_follows_the_workbench_frame() {
|
||||
let frame = include_str!("../../src/features/plugins/views/frame.ts");
|
||||
let csp = frame.split("FRAME_CSP =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
|
||||
assert_eq!(FRAME_HOST_CSP.strip_prefix("sandbox allow-scripts; ").unwrap(), csp);
|
||||
let tag = frame.split("FRAME_TAG =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
|
||||
assert!(FRAME_HOST.contains(&format!("d.tag !== \"{tag}\"")), "{FRAME_HOST}");
|
||||
assert!(frame.contains(&format!("FRAME_HOST_PATH = \"{FRAME_HOST_PATH}\"")));
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -79,6 +79,17 @@ const item = (name: string, kind: OutlineItem["kind"], line: number, children: O
|
||||
return { name, kind, detail: null, range: r, selection_range: r, children };
|
||||
};
|
||||
|
||||
describe("usages label", () => {
|
||||
it("is translated", async () => {
|
||||
const { usagesLabel } = await import("./codeLens");
|
||||
const { setLocale } = await import("@/platform/i18n");
|
||||
expect([0, 1, 3].map(usagesLabel)).toEqual(["no usages", "1 usage", "3 usages"]);
|
||||
await setLocale("zh");
|
||||
expect([0, 1, 3].map(usagesLabel)).toEqual(["无用法", "1 个用法", "3 个用法"]);
|
||||
await setLocale("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usages lens targets", () => {
|
||||
it("skips callbacks and anonymous functions", () => {
|
||||
const items = [
|
||||
|
||||
@@ -81,8 +81,8 @@ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
|
||||
}
|
||||
|
||||
export function usagesLabel(n: number): string {
|
||||
if (n === 0) return "no usages";
|
||||
return n === 1 ? "1 usage" : `${n} usages`;
|
||||
if (n === 0) return tx("no usages");
|
||||
return n === 1 ? tx("1 usage") : tx`${n} usages`;
|
||||
}
|
||||
|
||||
const setLenses = StateEffect.define<CodeLens[]>();
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("plugin-backed content editor tabs", () => {
|
||||
});
|
||||
await waitFor(() => expect(diffs).toHaveLength(1));
|
||||
expect(diffs[0]).toEqual({
|
||||
path: "service.toml",
|
||||
path: "plugin-diff:/service.toml",
|
||||
title: "Diff: service.toml",
|
||||
left: { type: "text", text: 'name = "demo"\nreplicas = 2\n', label: "Live" },
|
||||
right: { type: "text", text: 'name = "demo"\nreplicas = 5\n', label: "Edited" },
|
||||
@@ -239,18 +239,26 @@ describe("plugin-backed content editor tabs", () => {
|
||||
openPluginDiffRequest({ left: "", right: "b\n", title: "MR !12" }, 10);
|
||||
expect(diffs).toEqual([
|
||||
{
|
||||
path: "src/review.ts",
|
||||
path: "plugin-diff:/review.ts",
|
||||
title: "src/review.ts",
|
||||
left: { type: "text", text: "a\n", label: "Target" },
|
||||
right: { type: "text", text: "b\n", label: "After" },
|
||||
},
|
||||
{
|
||||
path: "MR !12",
|
||||
path: "plugin-diff:/MR !12",
|
||||
title: "MR !12",
|
||||
left: { type: "text", text: "", label: "Before" },
|
||||
right: { type: "text", text: "b\n", label: "After" },
|
||||
},
|
||||
]);
|
||||
// A file name only names the syntax: never a path on disk (a UNC share, say).
|
||||
openPluginDiffRequest({ left: "a", right: "b", fileName: "\\\\attacker\\share\\x.rs" }, 10);
|
||||
openPluginDiffRequest({ left: "a", right: "b", title: "C:\\Users\\x\\y.ts" }, 10);
|
||||
expect(diffs.slice(2).map((d) => (d as { path: string }).path)).toEqual([
|
||||
"plugin-diff:/x.rs",
|
||||
"plugin-diff:/y.ts",
|
||||
]);
|
||||
diffs.splice(2);
|
||||
expect(() => openPluginDiffRequest({ left: "a", right: 1 }, 10)).toThrow();
|
||||
expect(() => openPluginDiffRequest({ left: "a", right: "b", fileName: "x\ny" }, 10)).toThrow();
|
||||
expect(() => openPluginDiffRequest({ left: "a", right: "b" }, 4 << 20)).toThrow();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import type { View } from "@/bindings";
|
||||
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
|
||||
import { FRAME_TAG } from "../views/frame";
|
||||
import { PluginView } from "../views/PluginView";
|
||||
|
||||
vi.mock("@/platform/ipc", async (original) => ({ ...(await original()), isTauri: () => true }));
|
||||
|
||||
describe("html bodies in the app", () => {
|
||||
it("writes the document into the plugin scheme's host page, not a srcdoc that inherits the workbench CSP", async () => {
|
||||
const view: View = {
|
||||
toolbar: [],
|
||||
body: { kind: "html", value: "<p>chart</p><script>draw()</script>" },
|
||||
status: null,
|
||||
};
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend([]), renderView: vi.fn(async () => view) });
|
||||
const { container } = render(<PluginView pluginId="charts" view="chart" />);
|
||||
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
|
||||
const frame = container.querySelector("iframe")!;
|
||||
expect(frame.hasAttribute("srcdoc")).toBe(false);
|
||||
expect(frame.getAttribute("src")).toMatch(
|
||||
/^(openidea-plugin:\/\/localhost|http:\/\/openidea-plugin\.localhost)\/\.frame$/,
|
||||
);
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
|
||||
const post = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
// Not from the frame: ignored.
|
||||
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: window }));
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: frame.contentWindow }),
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
{ tag: FRAME_TAG, document: expect.stringContaining("<p>chart</p><script>draw()</script>") },
|
||||
"*",
|
||||
);
|
||||
const doc = (post.mock.calls[0]![0] as { document: string }).document;
|
||||
expect(doc).toContain('<meta http-equiv="Content-Security-Policy"');
|
||||
expect(doc).toContain("window.openidea");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { tx, VerbatimTextContext } from "@/platform/i18n";
|
||||
import {
|
||||
Checkbox,
|
||||
@@ -34,9 +35,9 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { pluginsBackend, webSchemeUrl } from "../backend";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { FRAME_HOST_PATH, FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { localizeView, usePluginT } from "../pluginText";
|
||||
import { currentViewLocation, onViewMessage, useViewLocation, useViewRevision } from "./viewStore";
|
||||
import { ServicePane } from "./ServicePane";
|
||||
@@ -674,7 +675,10 @@ function HtmlBody({
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
// The theme at load time is in the document; later changes are posted, so a
|
||||
// theme switch doesn't reload the frame and lose its state.
|
||||
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
const doc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
// In the app the document is written into a page of the plugin scheme: a `srcdoc` frame
|
||||
// would inherit the workbench's CSP, which blocks the document's inline scripts.
|
||||
const host = isTauri() ? webSchemeUrl(FRAME_HOST_PATH) : null;
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
const postTheme = useCallback(
|
||||
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
|
||||
@@ -685,8 +689,10 @@ function HtmlBody({
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source !== frame.current?.contentWindow) return;
|
||||
const data = e.data as { tag?: unknown; text?: unknown } | null;
|
||||
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
|
||||
const data = e.data as { tag?: unknown; text?: unknown; ready?: unknown } | null;
|
||||
if (data?.tag !== FRAME_TAG) return;
|
||||
if (data.ready === true) frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, document: doc }, "*");
|
||||
else if (typeof data.text === "string") send({ kind: "message", text: data.text });
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
const off = onViewMessage(pluginId, view, (text) =>
|
||||
@@ -696,18 +702,22 @@ function HtmlBody({
|
||||
window.removeEventListener("message", onMessage);
|
||||
off();
|
||||
};
|
||||
}, [pluginId, view, send]);
|
||||
}, [pluginId, view, send, doc]);
|
||||
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE.
|
||||
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
|
||||
return (
|
||||
// `onLoad` re-sends the theme in case it changed between building the document and the frame loading.
|
||||
// A new document loads a new frame (`key`), as a changed `srcdoc` does.
|
||||
return host ? (
|
||||
<iframe
|
||||
key={doc}
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={view}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
src={host}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
) : (
|
||||
<iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={doc} onLoad={postTheme} />
|
||||
);
|
||||
}
|
||||
@@ -176,9 +176,22 @@ function validDiff(v: unknown): ContentTabDiff | undefined {
|
||||
}
|
||||
|
||||
/** Opens a plugin's two texts in the diff viewer; `path` picks the syntax highlighting. */
|
||||
export function openPluginDiff(diff: ContentTabDiff, path: string, title: string) {
|
||||
/**
|
||||
* A plugin diff's path: the file name only picks the syntax highlighting. It is virtual, so the
|
||||
* diff viewer never probes or opens a file of that name (a plugin may lack file system access).
|
||||
*/
|
||||
export function pluginDiffPath(name: string): string {
|
||||
const base =
|
||||
name
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/[\u0000-\u001f:]/g, "") || "diff";
|
||||
return `plugin-diff:/${base}`;
|
||||
}
|
||||
|
||||
export function openPluginDiff(diff: ContentTabDiff, name: string, title: string) {
|
||||
void executeAction("vcs.openDiff", {
|
||||
path,
|
||||
path: pluginDiffPath(name),
|
||||
title: diff.title ?? title,
|
||||
left: { type: "text", text: diff.left, label: diff.leftLabel ?? tx("Before") },
|
||||
right: { type: "text", text: diff.right, label: diff.rightLabel ?? tx("After") },
|
||||
|
||||
@@ -19,6 +19,13 @@ export const FRAME_CSP =
|
||||
/** Marks frame ↔ workbench messages. */
|
||||
export const FRAME_TAG = "openidea-view";
|
||||
|
||||
/**
|
||||
* The page of the `openidea-plugin` scheme that a rendered document is written into
|
||||
* (src-tauri/src/plugin_web.rs). A `srcdoc` frame would inherit the workbench's CSP,
|
||||
* which blocks inline scripts; this page has only {@link FRAME_CSP}.
|
||||
*/
|
||||
export const FRAME_HOST_PATH = ".frame";
|
||||
|
||||
/**
|
||||
* The theme variables a frame gets: public name → IDE token. Part of the
|
||||
* plugin API: names are only ever added, so the IDE's own tokens can change.
|
||||
|
||||
@@ -471,7 +471,7 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<SearchField
|
||||
className={root ? styles.searchGrow : undefined}
|
||||
className={styles.searchGrow}
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { call, commands } from "@/platform/ipc";
|
||||
import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
|
||||
import { registerSoftWrapSurface } from "@/workbench/softWrap";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
@@ -819,7 +820,8 @@ export function DiffEditor({
|
||||
const fileSource = sideFile(left, path) ?? sideFile(right, path);
|
||||
const [onDisk, setOnDisk] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (fileSource) return;
|
||||
// A virtual path (a plugin's text diff) names no file on disk.
|
||||
if (fileSource || isVirtualUri(path)) return;
|
||||
let alive = true;
|
||||
void call(commands.fsProbe(path)).then(
|
||||
() => alive && setOnDisk(path),
|
||||
|
||||
@@ -138,6 +138,16 @@ describe("buildBranchRows", () => {
|
||||
expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]);
|
||||
});
|
||||
|
||||
it("matches a query as a substring or at word starts, not as letters scattered through the name", () => {
|
||||
const list = ["sprint-1.1-device-reset", "feature/test-paging", "fix/TestRunner", "bugfix/totals-reset"].map((n) =>
|
||||
b(n),
|
||||
);
|
||||
const search = (q: string) => summary(buildBranchRows(list, [], q, [], null, [], { groupByPrefix: false }));
|
||||
expect(search("test")).toEqual(["v Local", " local:feature/test-paging", " local:fix/TestRunner"]);
|
||||
// Word starts still work: `ftp` finds feature/test-paging.
|
||||
expect(search("ftp")).toEqual(["v Local", " local:feature/test-paging"]);
|
||||
});
|
||||
|
||||
it("omits empty groups", () => {
|
||||
expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["v Local", " local:main ★"]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Branches popup model: grouping, ordering and filtering (pure). */
|
||||
import type { Branch } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { fuzzyMatch } from "@/ui/match";
|
||||
import { wordMatch } from "@/ui/match";
|
||||
|
||||
export interface PopupAction {
|
||||
id: string;
|
||||
@@ -213,7 +213,7 @@ export function buildBranchRows(
|
||||
const q = query.trim();
|
||||
const match = (text: string): number[] | null | false => {
|
||||
if (!q) return null;
|
||||
return fuzzyMatch(text, q) ?? false;
|
||||
return wordMatch(text, q) ?? false;
|
||||
};
|
||||
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
|
||||
const rows: BranchRow[] = actionRows(actions, match, !q);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Branches popup model for a project with several repositories (pure). */
|
||||
import type { Branch } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { fuzzyMatch } from "@/ui/match";
|
||||
import { fuzzyMatch, wordMatch } from "@/ui/match";
|
||||
import { rootOf } from "./model";
|
||||
import {
|
||||
actionRows,
|
||||
@@ -173,7 +173,7 @@ export function buildMultiRepoRows({
|
||||
showTags = true,
|
||||
}: MultiRepoInput): RepoRow[] {
|
||||
const q = query.trim();
|
||||
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
|
||||
const match = (text: string): number[] | null | false => (q ? (wordMatch(text, q) ?? false) : null);
|
||||
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
|
||||
const blocks: RepoRow[][] = [];
|
||||
|
||||
@@ -181,7 +181,8 @@ export function buildMultiRepoRows({
|
||||
|
||||
const repoRows: RepoRow[] = [];
|
||||
repos.forEach((repo, i) => {
|
||||
const m = match(repo.name);
|
||||
// Repository names keep the loose match: the list is short and abbreviations like `deps` help.
|
||||
const m = q ? (fuzzyMatch(repo.name, q) ?? false) : null;
|
||||
if (m !== false)
|
||||
repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m });
|
||||
});
|
||||
|
||||
@@ -1469,5 +1469,8 @@
|
||||
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.",
|
||||
"Archived agent tabs to keep": "Archived agent tabs to keep",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
|
||||
"Archived ({count})": "Archived ({count})"
|
||||
"Archived ({count})": "Archived ({count})",
|
||||
"no usages": "no usages",
|
||||
"1 usage": "1 usage",
|
||||
"{n} usages": "{n} usages"
|
||||
}
|
||||
@@ -7867,5 +7867,8 @@
|
||||
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "智能体运行的命令以 _agent_ 标签显示在运行工具窗口中。已结束的标签超过这个数量时,最旧的会移入标签栏末尾的归档。正在运行的命令永远不会被归档。",
|
||||
"Archived agent tabs to keep": "保留的已归档智能体标签数",
|
||||
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
|
||||
"Archived ({count})": "归档({count})"
|
||||
"Archived ({count})": "归档({count})",
|
||||
"no usages": "无用法",
|
||||
"1 usage": "1 个用法",
|
||||
"{n} usages": "{n} 个用法"
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user