Compare commits

..
Author SHA1 Message Date
l0ng-ai 2dc078b93b feat(plugins): commit workflow API for plugins
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:12:10 +08:00
65 changed files with 94 additions and 1183 deletions

No files matched your search

Generated
-1
View File
@@ -4319,7 +4319,6 @@ name = "ide-host"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
"criterion",
"dirs 6.0.0",
"dunce",
-1
View File
@@ -17,7 +17,6 @@ ide-vfs.workspace = true
regex-syntax.workspace = true
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
+30 -152
View File
@@ -18,7 +18,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
const MAX_STORAGE: usize = 64 << 20;
const MAX_STORAGE: usize = 1 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
@@ -396,62 +396,11 @@ impl Shared {
}
}
/// The plugin's stored values: one file per key, so a write only
/// rewrites its own value.
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage")
pub fn storage_file(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage.json")
}
}
fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
}
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
Some((key, value))
}
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
std::fs::create_dir_all(dir).map_err(storage_io)?;
let file = dir.join(entry_name(key));
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
let list = match std::fs::read_dir(dir) {
Ok(list) => list,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
Err(e) => return Err(storage_io(e)),
};
let mut out = Vec::new();
for entry in list {
let entry = entry.map_err(storage_io)?;
let path = entry.path();
if path.extension().is_some() {
continue; // a write that didn't finish
}
let size = entry.metadata().map_err(storage_io)?.len();
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
}
Ok(out.into_iter())
}
pub(super) struct PluginApi {
pub pkg: Package,
pub shared: Arc<Shared>,
@@ -462,24 +411,21 @@ impl PluginApi {
self.pkg.id()
}
/// The storage directory, after moving a `storage.json` written by an
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
let map: BTreeMap<String, String> =
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
for (key, value) in &map {
write_entry(&dir, key, value)?;
}
std::fs::remove_file(&legacy).map_err(storage_io)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(storage_io(e)),
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
match std::fs::read(self.shared.storage_file(self.id())) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
}
Ok(dir)
}
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
let file = self.shared.storage_file(self.id());
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
}
/// The program approved for the (already checked) `process:` grant,
@@ -893,47 +839,30 @@ impl HostApi for PluginApi {
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
let file = self.storage()?.join(entry_name(key));
match std::fs::read(&file) {
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(storage_io(e)),
}
Ok(self.load_storage()?.get(key).cloned())
}
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
if key.is_empty() || key.len() > MAX_KEY {
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
}
let dir = self.storage()?;
let file = dir.join(entry_name(key));
let Some(value) = value else {
return match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
_ => Ok(()),
};
let mut map = self.load_storage()?;
let changed = match value {
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
None => map.remove(key).is_some(),
};
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
if others + key.len() + value.len() > MAX_STORAGE {
return Err(HostError::Invalid(format!(
"storage is limited to {} MiB (UTF-8 keys and values)",
MAX_STORAGE >> 20
)));
if !changed {
return Ok(());
}
write_entry(&dir, key, &value)
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
if size > MAX_STORAGE {
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
}
self.save_storage(&map)
}
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
let dir = self.storage()?;
let mut keys = Vec::new();
for (file, _) in entries(&dir)? {
let bytes = std::fs::read(&file).map_err(storage_io)?;
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
keys.push(key.to_owned());
}
}
keys.sort();
Ok(keys)
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
@@ -1363,57 +1292,6 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
let tmp = tempfile::tempdir().unwrap();
let legacy = tmp.path().join(".data/acme.lint/storage.json");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
assert!(!legacy.exists());
api.storage_set("a", Some("3".into())).unwrap();
api.storage_set("b/c", None).unwrap();
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
assert_eq!(api.storage_get("b/c").unwrap(), None);
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
let tmp = tempfile::tempdir().unwrap();
let value = "é".repeat(2 << 20);
{
let api = api(tmp.path(), Arc::default());
api.storage_set("index", Some(value.clone())).unwrap();
}
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
api.storage_set("index", None).unwrap();
assert_eq!(api.storage_get("index").unwrap(), None);
}
#[test]
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let key = "index";
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
value.push('x');
api.storage_set(key, Some(value.clone())).unwrap();
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
let err = api.storage_set(key, Some(value)).unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
}
assert_eq!(api.storage_keys("").unwrap(), [key]);
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
api.storage_set(key, Some("small".into())).unwrap();
api.storage_set("other", Some("value".into())).unwrap();
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
+1 -2
View File
@@ -232,8 +232,7 @@ fn the_commit_points_need_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same.
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n");
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
for (what, manifest) in [
(
"commit.message",
+4 -44
View File
@@ -361,9 +361,6 @@ fn read_file_is_confined_to_the_project() {
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
// A missing path inside the project is `not-found`, like the mock reports.
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
assert!(err.contains("not found"), "{err}");
// Without a project there is nothing to read.
let inv = Invocation::default();
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
@@ -1531,8 +1528,8 @@ fn official_key_promoter() {
assert_eq!(tips.len(), 1, "{tips:?}");
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
let stored = stored(&f, "openidea.key-promoter");
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
// Uninstalling removes its data.
f.host.uninstall("openidea.key-promoter").unwrap();
@@ -1540,43 +1537,6 @@ fn official_key_promoter() {
assert!(f.host.event_kinds().is_empty());
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
f.install(&pkg);
f.run("test.storage", "save", None).unwrap();
assert_eq!(f.host.instances(), 1);
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
let storage_dir = f.plugins_dir.join(".data/test.storage");
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
f.host.uninstall("test.storage").unwrap();
assert!(!storage_dir.exists());
f.install(&pkg);
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
}
/// A plugin's stored entries (key, value), read from its files.
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
let mut entries: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| {
let bytes = std::fs::read(e.unwrap().path()).unwrap();
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
})
.collect();
entries.sort();
entries
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
@@ -1641,8 +1601,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
)
);
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
let stored = stored(&f, "openidea.git-toolbox");
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
assert!(stored.contains("last-fetch:"), "{stored}");
}
/// Restricted Mode: git does not run in an untrusted project (no fetch).
+1 -1
View File
@@ -20,6 +20,6 @@ pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root};
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
pub use types::*;
pub use wasmtime::component::Component;
+3 -76
View File
@@ -5,53 +5,15 @@ use std::path::{Path, PathBuf};
/// Largest file `read-file` returns.
pub const MAX_READ_BYTES: u64 = 16 << 20;
/// The canonical `root` and the absolute form of `path` (absolute, or
/// relative to `root`) — before any symlink or existence check.
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("empty path".into());
}
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok((root, joined))
}
/// `joined` after `..`/symlink resolution as far as the file system allows:
/// the canonical path when it exists, else the canonicalized deepest existing
/// ancestor with the still-missing tail resolved textually — so the location
/// of a missing path is still judged where it would really land.
fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match candidate.canonicalize() {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
return joined.to_path_buf();
};
tail.push(last.as_os_str().to_owned());
if !candidate.pop() {
return joined.to_path_buf();
}
}
}
};
for part in tail.iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
}
real
}
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
@@ -59,15 +21,6 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
Ok(real)
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
/// followed; a missing tail is judged at its would-be location.
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
let (root, joined) = joined_path(root, path)?;
Ok(resolve_real(&joined).starts_with(&root))
}
/// Reads a UTF-8 file that `resolve_in_root` already approved.
pub fn read_text(real: &Path) -> Result<String, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
@@ -109,27 +62,6 @@ mod tests {
assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file"));
}
#[test]
fn inside_root_covers_missing_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.txt"), "hi").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
assert!(inside_root(&root, "src/a.txt").unwrap());
assert!(inside_root(&root, "src").unwrap());
assert!(inside_root(&root, "missing.txt").unwrap());
assert!(inside_root(&root, "missing/deep/nested.txt").unwrap());
assert!(inside_root(&root, "missing/../src/a.txt").unwrap());
assert!(!inside_root(&root, "../secret.txt").unwrap());
assert!(!inside_root(&root, "missing/../../secret.txt").unwrap());
assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap());
let abs_out = tmp.path().join("secret.txt");
assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap());
assert!(inside_root(&root, "").is_err());
}
#[cfg(unix)]
#[test]
fn symlinks_cannot_escape() {
@@ -139,11 +71,6 @@ mod tests {
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap();
assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside"));
assert!(!inside_root(&root, "link").unwrap());
assert!(!inside_root(&root, "link/../x").unwrap());
std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap();
assert!(!inside_root(&root, "dirlink/secret.txt").unwrap());
assert!(!inside_root(&root, "dirlink/missing.txt").unwrap());
}
#[test]
+1 -55
View File
@@ -424,10 +424,7 @@ impl workspace::HostProject for StoreData {
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
if let Some(text) = self.host.buffer_text(&real) {
return Ok(Ok(text));
@@ -435,57 +432,6 @@ impl workspace::HostProject for StoreData {
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
}
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
match sandbox::inside_root(&root, &path) {
Err(e) => Ok(Err(wit::Error::Invalid(e))),
Ok(false) => {
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
}
Ok(true) => {
let requested = Path::new(&path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok(Ok(joined.exists()))
}
}
}
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
};
let meta = match std::fs::symlink_metadata(&real) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Err(wit::Error::NotFound(format!("{path}: no such file"))));
}
Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))),
};
let kind = if meta.is_file() {
workspace::FileKind::File
} else if meta.is_dir() {
workspace::FileKind::Directory
} else if meta.is_symlink() {
workspace::FileKind::Symlink
} else {
workspace::FileKind::Other
};
let modified =
meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified }))
}
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
Binary file not shown.
@@ -1 +1 @@
83a1e3f362740752ed69c53da29ff950cf9c9de18125af6c98995ea490b31690
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
Binary file not shown.
@@ -1 +1 @@
a9f1e2ec87de32ef241fcb559d157cd05800dcf106ab27e906ba36468c0b39e5
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
Binary file not shown.
@@ -1 +1 @@
d065af9f54b5bd1ec2fbc31250c87153a55501a4e8bf4002fcc04c67c2d1e03e
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
Binary file not shown.
@@ -1 +1 @@
dd57b09293deb0fdc3201978e350c118f428429df4beccc4f23665be0335a795
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
Binary file not shown.
@@ -1 +1 @@
436516e5f17a11f5a1225b854ab9fef703172a47d33bf3d840c721ad6d373e62
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
Binary file not shown.
@@ -1 +1 @@
4e8118c9f4974385421d1e2ca0106fd658ad85124e1453c4114f60a164f72f68
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
Binary file not shown.
@@ -1 +1 @@
b77e7e3d163995f0a9a3f3cb49dc6c4ea04b2eaa0a8b74b46c64e83908bae504
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
-333
View File
@@ -1,333 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-storage"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "openidea-test-storage"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -1 +0,0 @@
9f2195fb83f16221e98313c095d57c293e79041ed8ae439fe2fb4f008a3a0921
-16
View File
@@ -1,16 +0,0 @@
id = "test.storage"
name = "Storage fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[capabilities]
required = ["storage"]
[[contributes.commands]]
id = "save"
title = "Save index"
[[contributes.commands]]
id = "load"
title = "Load index"
-25
View File
@@ -1,25 +0,0 @@
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct Storage;
impl Plugin for Storage {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
match id {
"save" => {
host::storage::set("index", &"é".repeat(2 << 20))?;
Ok(None)
}
"load" => {
let value = host::storage::get("index")?.ok_or("missing index")?;
if value != "é".repeat(2 << 20) {
return Err("index changed".into());
}
Ok(Some(value.len().to_string()))
}
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(Storage);
-29
View File
@@ -13,35 +13,6 @@ test("opens a file from the project tree and types into it", async ({ workbench:
await expect(editor.locator(".cm-content")).not.toContainText("// typed by e2e");
});
test("Delete edits text after the focused Project tree is remounted", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
const content = editor.locator(".cm-content");
await content.press("Meta+End");
await page.keyboard.press("Enter");
await page.keyboard.insertText("delete probe abc");
const line = editor.locator(".cm-line", { hasText: "delete probe" });
await expect(line).toHaveText("delete probe abc");
const tree = page.getByRole("tree", { name: "Project" });
await tree.focus();
await page.evaluate(async () => {
const keymapModule = "/src/platform/keymap.ts";
const layoutModule = "/src/workbench/toolwindows/store.ts";
const { applyKeymapSettings } = await import(/* @vite-ignore */ keymapModule);
const { moveToolWindow } = await import(/* @vite-ignore */ layoutModule);
applyKeymapSettings("intellij-windows", {});
moveToolWindow("project", "right");
});
await expect(page.locator('[data-toolwindow="project"]')).toBeVisible();
await expect(tree).not.toBeFocused();
await content.press("ArrowLeft");
await page.keyboard.press("Delete");
await expect(line).toHaveText("delete probe ab");
await page.keyboard.press("Backspace");
await expect(line).toHaveText("delete probe a");
});
test("⌘F opens the find bar and highlights matches; Escape returns to the editor", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
await editor.locator(".cm-content").click();
+2 -3
View File
@@ -239,7 +239,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
| `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
@@ -351,8 +351,7 @@ then run `openidea-plugin check`.
| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. |
| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). |
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. |
| `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). |
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a5cba00850ccde12896ed7a69ae336a1968e024e8f5fefb6a49f2adea10569e4
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
27d61fcc109561aeea56f3a37da0aba7b6c56fdf01ff918f2e62d5732832ba3d
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c86fae61f88b6903354b1fb706b0718f9036f82085653481d77ddc3f622b3a73
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b4d059850083339f826223700eda7df0c8b655a40e4832d3acda36dc0f76db9a
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b2977b3b82ad26126125c614281cbbf888bb8151ece9a43fbaa5459c7a8b97cc
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9f59f59b999475a8c4c626f1cfc3c77ef1b3c0684f0cbf9af148556c8f9d30c0
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0f175352e4fcfe3b07359afb7f4a20348a488a21012e89775c628d19277d8c8c
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d7e9b2801ca70df9a84ec458f0fc5c6b01b7d03c056807c53268e40ecbbb9d1a
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
50524b5be85f6bc040521f97adba6030263045dcacfeae8dbcf73ce7234e2616
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0c44311a5f9ddb1893be7c82d818297a05b15576d996a33ddc2389f9432c416c
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3e4e23976ce0ddb1e83813269eefc24008521cdf22757e6b5249f6f219f344d2
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
+2 -37
View File
@@ -265,26 +265,6 @@ mod imp {
p.find_files(glob, limit).map_err(error)
}
pub fn project_exists(p: &ProjectRes, path: &str) -> Result<bool, Error> {
p.exists(path).map_err(error)
}
pub fn project_stat(p: &ProjectRes, path: &str) -> Result<FileStat, Error> {
use wit::workspace::FileKind as K;
p.stat(path)
.map(|s| FileStat {
kind: match s.kind {
K::File => FileKind::File,
K::Directory => FileKind::Directory,
K::Symlink => FileKind::Symlink,
K::Other => FileKind::Other,
},
size: s.size,
modified: s.modified,
})
.map_err(error)
}
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S};
let items: Vec<D> = items
@@ -444,25 +424,11 @@ impl Project {
}
/// Reads a UTF-8 file inside the project (absolute or root-relative);
/// unsaved editor contents win. A missing path is `Error::NotFound`.
/// Needs `project.read`.
/// unsaved editor contents win. Needs `project.read`.
pub fn read_file(&self, path: &str) -> Result<String, Error> {
imp::project_read(&self.inner, path)
}
/// Whether `path` names an existing file or directory inside the project
/// — including hidden and ignored ones — without reading any content.
/// Needs `project.read`.
pub fn exists(&self, path: &str) -> Result<bool, Error> {
imp::project_exists(&self.inner, path)
}
/// Metadata of an existing path inside the project; a missing path is
/// `Error::NotFound`. Needs `project.read`.
pub fn stat(&self, path: &str) -> Result<FileStat, Error> {
imp::project_stat(&self.inner, path)
}
/// The repository containing the project (`None`: not under git). Needs `vcs.read`.
pub fn vcs_status(&self) -> Result<Option<RepositoryStatus>, Error> {
imp::vcs_status(&self.inner)
@@ -749,8 +715,7 @@ pub fn unsubscribe(kind: EventKind) {
imp::unsubscribe(kind)
}
/// The plugin's persistent key-value store (≤ 64 MiB of UTF-8 keys and values).
/// Needs `storage`; rejected writes preserve the stored data.
/// The plugin's persistent key-value store (≤ 1 MiB). Needs `storage`.
pub mod storage {
use super::imp;
use crate::types::Error;
+4 -84
View File
@@ -55,7 +55,6 @@ struct State {
root: Option<String>,
other_projects: Vec<String>,
files: BTreeMap<String, String>,
dirs: BTreeSet<String>,
editor: Option<Editor>,
selected_paths: Vec<String>,
notifications: Vec<Notification>,
@@ -150,12 +149,6 @@ impl MockHost {
self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned());
}
/// A project directory `exists` and `stat` report (project-relative or
/// absolute path); file paths count as existing too.
pub fn add_dir(&self, path: &str) {
self.state.borrow_mut().dirs.insert(path.to_owned());
}
/// Sets the paths invocations report as selected.
pub fn select_paths(&self, paths: &[&str]) {
self.state.borrow_mut().selected_paths = paths.iter().map(|p| (*p).to_owned()).collect();
@@ -534,31 +527,14 @@ pub mod current {
})
}
/// `path` inside `p`'s root as a project-relative key, or `Invalid` for a
/// path leaving the root (as the host does).
fn rel_in_root<'a>(p: &ProjectRes, path: &'a str) -> Result<&'a str, Error> {
let root = p.root.as_str();
let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path);
if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") {
return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed")));
}
Ok(rel)
}
fn path_known(s: &State, p: &ProjectRes, path: &str, rel: &str) -> bool {
let root = p.root.as_str();
s.files.contains_key(rel)
|| s.files.contains_key(path)
|| s.dirs.contains(rel)
|| s.dirs.contains(path)
|| s.editor.as_ref().is_some_and(|e| e.path == path || e.path == format!("{root}/{rel}"))
}
pub fn project_read(p: &ProjectRes, path: &str) -> Result<String, Error> {
with(|s| {
require(s, "project.read")?;
let root = p.root.as_str();
let rel = rel_in_root(p, path)?;
let rel = path.strip_prefix(root).map(|p| p.trim_start_matches('/')).unwrap_or(path);
if rel.starts_with('/') || rel.split('/').any(|seg| seg == "..") {
return Err(Error::Invalid(format!("{path}: access outside the project root is not allowed")));
}
let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{root}/{rel}"));
if let Some(e) = open {
return Ok(e.text.clone());
@@ -567,33 +543,6 @@ pub mod current {
})
}
pub fn project_exists(p: &ProjectRes, path: &str) -> Result<bool, Error> {
with(|s| {
require(s, "project.read")?;
let rel = rel_in_root(p, path)?;
Ok(rel.is_empty() || path_known(s, p, path, rel))
})
}
pub fn project_stat(p: &ProjectRes, path: &str) -> Result<FileStat, Error> {
with(|s| {
require(s, "project.read")?;
let rel = rel_in_root(p, path)?;
if !path_known(s, p, path, rel) {
return if rel.is_empty() {
Ok(FileStat { kind: FileKind::Directory, size: 0, modified: None })
} else {
Err(Error::NotFound(path.into()))
};
}
let file = s.files.get(rel).or_else(|| s.files.get(path));
let open = s.editor.as_ref().filter(|e| e.path == path || e.path == format!("{}/{rel}", p.root));
let kind = if file.is_some() || open.is_some() { FileKind::File } else { FileKind::Directory };
let size = file.or_else(|| open.map(|e| &e.text)).map_or(0, |t| t.len() as u64);
Ok(FileStat { kind, size, modified: None })
})
}
pub fn invocation_project(_: InvocationRef<'_>) -> Option<ProjectRes> {
with(|s| s.root.clone().map(|root| MockProject { root }))
}
@@ -942,35 +891,6 @@ mod tests {
assert!(mock.tasks()[0].ended);
}
#[test]
fn exists_and_stat_report_missing_as_not_found() {
let mock = MockHost::install();
mock.add_file("src/a.txt", "hello");
mock.add_dir("src");
mock.add_dir(".hidden");
let project = mock.invocation().project().unwrap();
assert!(project.exists("src/a.txt").unwrap());
assert!(project.exists("src").unwrap());
assert!(project.exists(".hidden").unwrap());
assert!(project.exists("/project/src/a.txt").unwrap());
assert!(!project.exists("missing.txt").unwrap());
assert!(!project.exists("missing/dir").unwrap());
assert!(matches!(project.exists("../outside"), Err(Error::Invalid(_))));
assert!(matches!(project.read_file("missing.txt"), Err(Error::NotFound(_))));
let file = project.stat("src/a.txt").unwrap();
assert_eq!((file.kind, file.size), (FileKind::File, 5));
let dir = project.stat("src").unwrap();
assert_eq!(dir.kind, FileKind::Directory);
assert!(matches!(project.stat("missing.txt"), Err(Error::NotFound(_))));
assert!(matches!(project.stat("../outside"), Err(Error::Invalid(_))));
let read_only = MockHost::install().with_capabilities(&[]);
let project = read_only.invocation().project().unwrap();
assert!(matches!(project.exists("x"), Err(Error::Denied(_))));
assert!(matches!(project.stat("x"), Err(Error::Denied(_))));
}
#[test]
fn versioned_edits() {
let mock = MockHost::install();
-20
View File
@@ -294,26 +294,6 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// What a path inside a project is, as [`crate::host::Project::stat`] reports it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FileKind {
File,
Directory,
Symlink,
#[default]
Other,
}
/// Metadata of a path inside a project.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FileStat {
pub kind: FileKind,
/// Bytes, for a file.
pub size: u64,
/// Seconds since the Unix epoch; `None` when the file system does not track it.
pub modified: Option<u64>,
}
/// A file the commit being prepared changes ([`crate::host::pending_commit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitChange {
+1 -2
View File
@@ -68,8 +68,7 @@ interface ui {
}
/// Persistent key-value storage of the plugin (survives restarts and idle
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
interface storage {
use types.{error};
+1 -29
View File
@@ -3,42 +3,14 @@ package openidea:[email protected];
interface workspace {
use types.{error};
/// What a path inside a project is.
enum file-kind {
file,
directory,
symlink,
other,
}
/// Metadata of a path inside a project, as `stat` reports it.
record file-stat {
kind: file-kind,
/// Bytes, for a file.
size: u64,
/// Seconds since the Unix epoch; `none` when the file system does
/// not track it.
modified: option<u64>,
}
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
root: func() -> result<string, error>;
/// Reads a UTF-8 text file inside the project (absolute, or relative
/// to the root); paths leaving the root are refused. Unsaved editor
/// contents win over the disk. A missing path is `not-found`.
/// Requires `project.read`.
/// contents win over the disk. Requires `project.read`.
read-file: func(path: string) -> result<string, error>;
/// Whether `path` (absolute, or relative to the root) names an
/// existing file or directory — including hidden and ignored ones,
/// without reading any content. Paths leaving the root are refused.
/// Requires `project.read`.
exists: func(path: string) -> result<bool, error>;
/// Metadata of an existing path inside the project (absolute, or
/// relative to the root); a missing path is `not-found` and paths
/// leaving the root are refused. Requires `project.read`.
stat: func(path: string) -> result<file-stat, error>;
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
/// at most `limit`, as absolute paths. Ignored and excluded files are
/// skipped. Requires `project.read`.
-2
View File
@@ -211,8 +211,6 @@ pub fn run() {
window_groups::frame_changed(window.app_handle(), window.label());
}
tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")]
titlebar::forget(window);
if windows::auxiliary_parent(window.label()).is_none() {
window_groups::destroyed(window.app_handle(), window.label());
windows::close_auxiliary_windows(window.app_handle(), window.label());
+7 -168
View File
@@ -2,190 +2,29 @@
//!
//! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so
//! nothing moves them behind its back and they stay still during live resize.
//!
//! In native full screen the same toolbar is drawn as an opaque strip over the web title bar, so
//! it is hidden from the moment the window starts entering full screen until it has left (#1899).
use objc2::{
MainThreadMarker,
rc::{Retained, Weak},
runtime::ProtocolObject,
};
use objc2_app_kit::{
NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
NSWindowWillEnterFullScreenNotification,
};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSToolbar, NSWindow, NSWindowToolbarStyle};
/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar.
const COMPACT_MAX_HEIGHT: f64 = 45.0;
fn style_for(height: f64) -> NSWindowToolbarStyle {
if height < COMPACT_MAX_HEIGHT { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified }
}
/// Whether a window is in, or on its way into, native full screen. `styleMask` cannot answer
/// this: its full screen flag only turns on at `didEnterFullScreen`, after the resize.
#[derive(Default)]
struct Fullscreen(Cell<bool>);
impl Fullscreen {
fn will_enter(&self) {
self.0.set(true);
}
fn did_exit(&self) {
self.0.set(false);
}
fn toolbar_visible(&self) -> bool {
!self.0.get()
}
}
struct Observer(Retained<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for Observer {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
}
}
struct Tracked {
state: Rc<Fullscreen>,
_observers: [Observer; 2],
}
thread_local! {
static WINDOWS: RefCell<HashMap<String, Tracked>> = RefCell::default();
}
fn apply(window: &NSWindow, state: &Fullscreen) {
if let Some(toolbar) = window.toolbar() {
let visible = state.toolbar_visible();
if toolbar.isVisible() != visible {
toolbar.setVisible(visible);
}
}
}
fn track(window: &NSWindow) -> Tracked {
let state = Rc::new(Fullscreen::default());
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
state.will_enter();
}
let observe = |name, change: fn(&Fullscreen)| {
let weak = Weak::new(window);
let state = state.clone();
let block = block2::RcBlock::new(move |_| {
change(&state);
if let Some(window) = weak.load() {
apply(&window, &state);
}
});
let observer = unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(name),
Some(window),
None,
&block,
)
};
Observer(observer)
};
let observers = unsafe {
[
observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter),
observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit),
]
};
Tracked { state, _observers: observers }
}
pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> {
let label = window.label().to_owned();
// with_webview runs on the AppKit thread.
window.with_webview(move |webview| unsafe {
let Some(mtm) = MainThreadMarker::new() else { return };
let native = &*webview.ns_window().cast::<NSWindow>();
let state =
WINDOWS.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native)).state.clone());
if native.toolbar().is_none() {
let toolbar = NSToolbar::new(mtm);
native.setToolbar(Some(&toolbar));
}
let style = style_for(height);
let style = if height < COMPACT_MAX_HEIGHT {
NSWindowToolbarStyle::UnifiedCompact
} else {
NSWindowToolbarStyle::Unified
};
if native.toolbarStyle() != style {
native.setToolbarStyle(style);
}
apply(native, &state);
})
}
/// Stops tracking a destroyed window, which removes its notification observers.
pub fn forget(window: &tauri::Window) {
let label = window.label().to_owned();
let forget = move || {
WINDOWS.with_borrow_mut(|windows| windows.remove(&label));
};
if MainThreadMarker::new().is_some() {
forget();
} else {
let _ = window.run_on_main_thread(forget);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Debug)]
enum Step {
WillEnter,
DidExit,
}
/// Visibility after each step, starting from a window on the desktop.
#[test]
fn toolbar_follows_native_full_screen() {
use Step::*;
let cases: &[(&[Step], &[bool])] = &[
(&[], &[]),
(&[WillEnter], &[false]),
(&[WillEnter, DidExit], &[false, true]),
// `set_height` between the two notifications must not bring the toolbar back.
(&[WillEnter, WillEnter], &[false, false]),
// A second exit, as a group member leaving after its host did, changes nothing.
(&[WillEnter, DidExit, DidExit], &[false, true, true]),
(&[DidExit], &[true]),
(&[WillEnter, DidExit, WillEnter], &[false, true, false]),
];
for (steps, expected) in cases {
let state = Fullscreen::default();
assert!(state.toolbar_visible(), "a new window is on the desktop");
let seen: Vec<bool> = steps
.iter()
.map(|step| {
match step {
WillEnter => state.will_enter(),
DidExit => state.did_exit(),
}
state.toolbar_visible()
})
.collect();
assert_eq!(&seen, expected, "{steps:?}");
}
}
#[test]
fn toolbar_style_follows_the_web_bar_height() {
for (height, compact) in
[(31.9, true), (32.0, true), (40.0, true), (44.9, true), (45.0, false), (52.0, false), (120.0, false)]
{
let expected = if compact { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified };
assert_eq!(style_for(height), expected, "{height}");
}
}
}
+6 -2
View File
@@ -7,6 +7,7 @@
*/
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import {
@@ -377,10 +378,13 @@ export function DatabaseView() {
return (
<div className={styles.dbView}>
<DatabaseToolbar />
<div className={styles.treeHost}>
<div
className={styles.treeHost}
onFocus={(e) => setContext(DB_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")}
onBlur={() => setContext(DB_VIEW_FOCUS, false)}
>
<Tree<Node>
aria-label="Data sources"
focusContext={DB_VIEW_FOCUS}
roots={roots}
handleRef={tree}
expanded={st.treeExpanded}
+3 -1
View File
@@ -20,6 +20,7 @@ import { createPortal } from "react-dom";
import clsx from "clsx";
import { revealRange, useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { tx, useTxTemplate } from "@/platform/i18n";
import { isTauri } from "@/platform/ipc";
import { nativeDropPoint } from "@/platform/nativeDrop";
@@ -778,6 +779,8 @@ export function ProjectView() {
onCopy={(e) => onClipboard(e, "project.copy")}
onCut={(e) => onClipboard(e, "project.cut")}
onPaste={(e) => onClipboard(e, "project.paste")}
onFocus={(e) => setContext("projectViewFocus", (e.target as HTMLElement).getAttribute("role") === "tree")}
onBlur={() => setContext("projectViewFocus", false)}
>
{mode === "unity" && unity && !unity.projects.length && (
<div className={styles.unityNotice} role="status">
@@ -792,7 +795,6 @@ export function ProjectView() {
<Tree<ProjectNode>
horizontalScroll
aria-label="Project"
focusContext="projectViewFocus"
handleRef={setTreeHandle}
roots={model.roots}
getKey={getKey}
+8 -2
View File
@@ -5,6 +5,7 @@
*/
import { useEffect, useMemo, useRef } from "react";
import clsx from "clsx";
import { setContext } from "@/platform/context";
import { useTxTemplate, tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { useProject } from "@/platform/project";
@@ -207,10 +208,15 @@ export function ServicesTreeView() {
) : (
<SplitGroup orientation="horizontal" className={styles.split}>
<SplitPanel defaultSize="40%" minSize="160px">
<div className={styles.tree}>
<div
className={styles.tree}
onFocus={(e) =>
setContext(SERVICES_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")
}
onBlur={() => setContext(SERVICES_VIEW_FOCUS, false)}
>
<Tree<ServiceTreeItem>
key={location}
focusContext={SERVICES_VIEW_FOCUS}
reorder={reorder}
roots={roots}
getKey={(n) => n.key}
-28
View File
@@ -30,7 +30,6 @@ import { fuzzyMatch } from "./match";
import { acquirePopup } from "./popupState";
import styles from "./Tree.module.css";
import { T } from "./T";
import { setContext } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { useTreeReorder, type TreeReorder } from "./treeReorder";
import { useTreeDirectoryContext } from "./treeDirectoryContext";
@@ -117,8 +116,6 @@ export interface TreeProps<T> {
rowClassName?: (node: T) => string | undefined;
"aria-label"?: string;
handleRef?: Ref<TreeHandle>;
/** Context key set while the tree itself has keyboard focus. */
focusContext?: string;
empty?: ReactNode;
/** Rows wider than the pane scroll horizontally instead of clipping. */
horizontalScroll?: boolean;
@@ -595,31 +592,6 @@ export function Tree<T>(props: TreeProps<T>) {
);
const noMatch = !!query && matchMap.size === 0;
const { focusContext } = props;
useLayoutEffect(() => {
const element = scrollRef.current;
if (!focusContext || !element) return;
let focused = false;
const focus = () => {
focused = true;
setContext(focusContext, true);
};
const blur = () => {
if (!focused) return;
focused = false;
setContext(focusContext, false);
};
element.addEventListener("focus", focus);
element.addEventListener("blur", blur);
if (element.ownerDocument.activeElement === element) focus();
return () => {
element.removeEventListener("focus", focus);
element.removeEventListener("blur", blur);
// Removing a focused tree can skip blur, leaving its shortcuts active.
blur();
};
}, [emptyState, focusContext]);
if (emptyState) return <div className={clsx(styles.tree, styles.emptyWrap, className)}>{empty}</div>;
return (
+1 -2
View File
@@ -68,8 +68,7 @@ interface ui {
}
/// Persistent key-value storage of the plugin (survives restarts and idle
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
interface storage {
use types.{error};
+1 -29
View File
@@ -3,42 +3,14 @@ package openidea:[email protected];
interface workspace {
use types.{error};
/// What a path inside a project is.
enum file-kind {
file,
directory,
symlink,
other,
}
/// Metadata of a path inside a project, as `stat` reports it.
record file-stat {
kind: file-kind,
/// Bytes, for a file.
size: u64,
/// Seconds since the Unix epoch; `none` when the file system does
/// not track it.
modified: option<u64>,
}
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
root: func() -> result<string, error>;
/// Reads a UTF-8 text file inside the project (absolute, or relative
/// to the root); paths leaving the root are refused. Unsaved editor
/// contents win over the disk. A missing path is `not-found`.
/// Requires `project.read`.
/// contents win over the disk. Requires `project.read`.
read-file: func(path: string) -> result<string, error>;
/// Whether `path` (absolute, or relative to the root) names an
/// existing file or directory — including hidden and ignored ones,
/// without reading any content. Paths leaving the root are refused.
/// Requires `project.read`.
exists: func(path: string) -> result<bool, error>;
/// Metadata of an existing path inside the project (absolute, or
/// relative to the root); a missing path is `not-found` and paths
/// leaving the root are refused. Requires `project.read`.
stat: func(path: string) -> result<file-stat, error>;
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
/// at most `limit`, as absolute paths. Ignored and excluded files are
/// skipped. Requires `project.read`.