mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e9ccb4dd | ||
|
|
17a121e7f5 | ||
|
|
303d5950bf | ||
|
|
4cfdb972eb | ||
|
|
d235c432ec | ||
|
|
d3ea6f6daf |
No files matched your search
Generated
+1
@@ -4319,6 +4319,7 @@ name = "ide-host"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
|
||||
@@ -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 = 1 << 20;
|
||||
const MAX_STORAGE: usize = 64 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
@@ -396,11 +396,62 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_file(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage.json")
|
||||
/// 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")
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
@@ -411,21 +462,24 @@ impl PluginApi {
|
||||
self.pkg.id()
|
||||
}
|
||||
|
||||
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}"))),
|
||||
/// 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 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)
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The program approved for the (already checked) `process:` grant,
|
||||
@@ -839,30 +893,47 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
|
||||
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
|
||||
Ok(self.load_storage()?.get(key).cloned())
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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(()),
|
||||
};
|
||||
};
|
||||
if !changed {
|
||||
return Ok(());
|
||||
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
|
||||
)));
|
||||
}
|
||||
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)
|
||||
write_entry(&dir, key, &value)
|
||||
}
|
||||
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
|
||||
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
|
||||
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)
|
||||
}
|
||||
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
|
||||
@@ -1292,6 +1363,57 @@ 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();
|
||||
|
||||
@@ -232,7 +232,8 @@ 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();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).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");
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
|
||||
@@ -361,6 +361,9 @@ 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;
|
||||
@@ -1528,8 +1531,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 = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
|
||||
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
|
||||
let stored = stored(&f, "openidea.key-promoter");
|
||||
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
|
||||
|
||||
// Uninstalling removes its data.
|
||||
f.host.uninstall("openidea.key-promoter").unwrap();
|
||||
@@ -1537,6 +1540,43 @@ 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"])
|
||||
@@ -1601,8 +1641,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
)
|
||||
);
|
||||
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
|
||||
assert!(stored.contains("last-fetch:"), "{stored}");
|
||||
let stored = stored(&f, "openidea.git-toolbox");
|
||||
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
|
||||
}
|
||||
|
||||
/// Restricted Mode: git does not run in an untrusted project (no fetch).
|
||||
|
||||
@@ -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, read_text, resolve_in_root};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
@@ -5,15 +5,53 @@ use std::path::{Path, PathBuf};
|
||||
/// Largest file `read-file` returns.
|
||||
pub const MAX_READ_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
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"));
|
||||
@@ -21,6 +59,15 @@ 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())?;
|
||||
@@ -62,6 +109,27 @@ 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() {
|
||||
@@ -71,6 +139,11 @@ 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]
|
||||
|
||||
@@ -424,7 +424,10 @@ 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) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
@@ -432,6 +435,57 @@ 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 @@
|
||||
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
|
||||
83a1e3f362740752ed69c53da29ff950cf9c9de18125af6c98995ea490b31690
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
|
||||
a9f1e2ec87de32ef241fcb559d157cd05800dcf106ab27e906ba36468c0b39e5
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
|
||||
d065af9f54b5bd1ec2fbc31250c87153a55501a4e8bf4002fcc04c67c2d1e03e
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
|
||||
dd57b09293deb0fdc3201978e350c118f428429df4beccc4f23665be0335a795
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
|
||||
436516e5f17a11f5a1225b854ab9fef703172a47d33bf3d840c721ad6d373e62
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
|
||||
4e8118c9f4974385421d1e2ca0106fd658ad85124e1453c4114f60a164f72f68
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
|
||||
b77e7e3d163995f0a9a3f3cb49dc6c4ea04b2eaa0a8b74b46c64e83908bae504
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,20 @@
|
||||
[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.
@@ -0,0 +1 @@
|
||||
9f2195fb83f16221e98313c095d57c293e79041ed8ae439fe2fb4f008a3a0921
|
||||
@@ -0,0 +1,16 @@
|
||||
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"
|
||||
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
@@ -13,6 +13,35 @@ 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();
|
||||
|
||||
+3
-2
@@ -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` (1 MiB per plugin) |
|
||||
| `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) |
|
||||
| `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,7 +351,8 @@ 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. |
|
||||
| `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). |
|
||||
| `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_. |
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
|
||||
a5cba00850ccde12896ed7a69ae336a1968e024e8f5fefb6a49f2adea10569e4
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
|
||||
27d61fcc109561aeea56f3a37da0aba7b6c56fdf01ff918f2e62d5732832ba3d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
|
||||
c86fae61f88b6903354b1fb706b0718f9036f82085653481d77ddc3f622b3a73
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
|
||||
b4d059850083339f826223700eda7df0c8b655a40e4832d3acda36dc0f76db9a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
|
||||
b2977b3b82ad26126125c614281cbbf888bb8151ece9a43fbaa5459c7a8b97cc
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
|
||||
9f59f59b999475a8c4c626f1cfc3c77ef1b3c0684f0cbf9af148556c8f9d30c0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
|
||||
0f175352e4fcfe3b07359afb7f4a20348a488a21012e89775c628d19277d8c8c
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
|
||||
d7e9b2801ca70df9a84ec458f0fc5c6b01b7d03c056807c53268e40ecbbb9d1a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
|
||||
50524b5be85f6bc040521f97adba6030263045dcacfeae8dbcf73ce7234e2616
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
|
||||
0c44311a5f9ddb1893be7c82d818297a05b15576d996a33ddc2389f9432c416c
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
|
||||
3e4e23976ce0ddb1e83813269eefc24008521cdf22757e6b5249f6f219f344d2
|
||||
@@ -265,6 +265,26 @@ 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
|
||||
@@ -424,11 +444,25 @@ impl Project {
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file inside the project (absolute or root-relative);
|
||||
/// unsaved editor contents win. Needs `project.read`.
|
||||
/// unsaved editor contents win. A missing path is `Error::NotFound`.
|
||||
/// 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)
|
||||
@@ -715,7 +749,8 @@ pub fn unsubscribe(kind: EventKind) {
|
||||
imp::unsubscribe(kind)
|
||||
}
|
||||
|
||||
/// The plugin's persistent key-value store (≤ 1 MiB). Needs `storage`.
|
||||
/// The plugin's persistent key-value store (≤ 64 MiB of UTF-8 keys and values).
|
||||
/// Needs `storage`; rejected writes preserve the stored data.
|
||||
pub mod storage {
|
||||
use super::imp;
|
||||
use crate::types::Error;
|
||||
|
||||
@@ -55,6 +55,7 @@ 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>,
|
||||
@@ -149,6 +150,12 @@ 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();
|
||||
@@ -527,14 +534,31 @@ 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 = 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 rel = rel_in_root(p, path)?;
|
||||
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());
|
||||
@@ -543,6 +567,33 @@ 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 }))
|
||||
}
|
||||
@@ -891,6 +942,35 @@ 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();
|
||||
|
||||
@@ -294,6 +294,26 @@ 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 {
|
||||
|
||||
@@ -68,7 +68,8 @@ interface ui {
|
||||
}
|
||||
|
||||
/// Persistent key-value storage of the plugin (survives restarts and idle
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
|
||||
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
|
||||
interface storage {
|
||||
use types.{error};
|
||||
|
||||
|
||||
@@ -3,14 +3,42 @@ 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. Requires `project.read`.
|
||||
/// contents win over the disk. A missing path is `not-found`.
|
||||
/// 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`.
|
||||
|
||||
@@ -211,6 +211,8 @@ 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());
|
||||
|
||||
+168
-7
@@ -2,29 +2,190 @@
|
||||
//!
|
||||
//! 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;
|
||||
use objc2_app_kit::{NSToolbar, NSWindow, NSWindowToolbarStyle};
|
||||
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};
|
||||
|
||||
/// 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 = if height < COMPACT_MAX_HEIGHT {
|
||||
NSWindowToolbarStyle::UnifiedCompact
|
||||
} else {
|
||||
NSWindowToolbarStyle::Unified
|
||||
};
|
||||
let style = style_for(height);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
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 {
|
||||
@@ -378,13 +377,10 @@ export function DatabaseView() {
|
||||
return (
|
||||
<div className={styles.dbView}>
|
||||
<DatabaseToolbar />
|
||||
<div
|
||||
className={styles.treeHost}
|
||||
onFocus={(e) => setContext(DB_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")}
|
||||
onBlur={() => setContext(DB_VIEW_FOCUS, false)}
|
||||
>
|
||||
<div className={styles.treeHost}>
|
||||
<Tree<Node>
|
||||
aria-label="Data sources"
|
||||
focusContext={DB_VIEW_FOCUS}
|
||||
roots={roots}
|
||||
handleRef={tree}
|
||||
expanded={st.treeExpanded}
|
||||
|
||||
@@ -20,7 +20,6 @@ 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";
|
||||
@@ -779,8 +778,6 @@ 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">
|
||||
@@ -795,6 +792,7 @@ export function ProjectView() {
|
||||
<Tree<ProjectNode>
|
||||
horizontalScroll
|
||||
aria-label="Project"
|
||||
focusContext="projectViewFocus"
|
||||
handleRef={setTreeHandle}
|
||||
roots={model.roots}
|
||||
getKey={getKey}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
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";
|
||||
@@ -208,15 +207,10 @@ export function ServicesTreeView() {
|
||||
) : (
|
||||
<SplitGroup orientation="horizontal" className={styles.split}>
|
||||
<SplitPanel defaultSize="40%" minSize="160px">
|
||||
<div
|
||||
className={styles.tree}
|
||||
onFocus={(e) =>
|
||||
setContext(SERVICES_VIEW_FOCUS, (e.target as HTMLElement).getAttribute("role") === "tree")
|
||||
}
|
||||
onBlur={() => setContext(SERVICES_VIEW_FOCUS, false)}
|
||||
>
|
||||
<div className={styles.tree}>
|
||||
<Tree<ServiceTreeItem>
|
||||
key={location}
|
||||
focusContext={SERVICES_VIEW_FOCUS}
|
||||
reorder={reorder}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
|
||||
@@ -30,6 +30,7 @@ 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";
|
||||
@@ -116,6 +117,8 @@ 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;
|
||||
@@ -592,6 +595,31 @@ 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 (
|
||||
|
||||
+2
-1
@@ -68,7 +68,8 @@ interface ui {
|
||||
}
|
||||
|
||||
/// Persistent key-value storage of the plugin (survives restarts and idle
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
|
||||
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
|
||||
interface storage {
|
||||
use types.{error};
|
||||
|
||||
|
||||
+29
-1
@@ -3,14 +3,42 @@ 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. Requires `project.read`.
|
||||
/// contents win over the disk. A missing path is `not-found`.
|
||||
/// 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`.
|
||||
|
||||
Reference in new issue
Block a user