Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms

# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
This commit is contained in:
l0ng-ai committed 2026-11-07 23:38:20 +08:00
commit 97abcc4dc8
91 files changed
+2283 -123

No files matched your search

+9 -7
View File
@@ -221,6 +221,7 @@ struct TaskState {
/// A running agent and its session.
#[derive(Clone)]
struct Live {
sign_in_message: String,
peer: acp::Conn,
session: String,
capabilities: AgentCapabilities,
@@ -1301,11 +1302,7 @@ impl Task {
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
let provider = self.state.lock().task.provider.clone();
Failure::from(match self.shared.spec(&provider) {
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
None => message,
})
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
}
e => Failure::from(self.describe(e)),
}
@@ -1323,6 +1320,11 @@ impl Task {
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
};
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
let launch_cwd = cwd.clone();
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1368,7 +1370,7 @@ impl Task {
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
sign_in_message.clone()
}
e => self.describe(e),
})?;
@@ -1440,7 +1442,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
+134 -1
View File
@@ -81,6 +81,64 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
impl ProviderSpec {
pub(crate) fn login_hint(&self, program: &Path) -> String {
if !program.is_absolute() {
return self.login_hint.clone();
}
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
let builtin = builtin_spec(&self.id);
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint.clone()),
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
@@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
+49
View File
@@ -11,6 +11,7 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
}
@@ -87,6 +88,11 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
@@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
for auth_on_new in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() });
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd");
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
program.to_string_lossy().into_owned();
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A settings change must not redirect guidance for the already running process.
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
"different-cursor".into();
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
+2
View File
@@ -15,6 +15,8 @@ use std::sync::Arc;
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
+1 -1
View File
@@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.extra_json,
+90 -3
View File
@@ -1,7 +1,11 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
struct CratesIo;
impl DependencyVersionSource for CratesIo {
@@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo {
}
}
struct GoModules {
root: PathBuf,
env: BTreeMap<String, String>,
}
impl DependencyVersionSource for GoModules {
fn id(&self) -> &'static str {
"go-modules"
}
fn cache_key(&self, package: &str) -> Option<String> {
let mut hash = DefaultHasher::new();
(&self.root, &self.env, package).hash(&mut hash);
Some(format!("{}-{:016x}", self.id(), hash.finish()))
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let search =
self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path);
let go = ide_core::tool_path::find_command("go", &search, &self.root)
.ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?;
// A partially edited go.mod is often invalid. Query outside it while
// keeping the SDK and proxy/private module configuration.
let dir = tempfile::tempdir()?;
let mut cmd = Command::new(go);
cmd.current_dir(dir.path())
.env("PATH", search)
.envs(&self.env)
.env("GO111MODULE", "on")
.env("GOFLAGS", "")
.env("GOWORK", "off")
.env("GIT_TERMINAL_PROMPT", "0")
.args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]);
let query = cancel.child_token();
// Fetches run on Host::blocking's Tokio pool, which keeps a runtime current.
let deadline = tokio::spawn({
let query = query.clone();
async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
query.cancel();
}
});
let output = ide_tools::run_streamed(cmd, &query, &|_| {});
deadline.abort();
Ok(go::listed_versions(&output?))
}
}
#[cfg(all(test, unix))]
#[path = "dependencies/go_tests.rs"]
mod go_tests;
fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult<GoModules> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
let root = member.unwrap_or_else(|| project.root.clone());
let mut env = BTreeMap::new();
ide_sdk::go::configure_environment(&root, &settings, &mut env);
Ok(GoModules { root, env })
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
@@ -31,11 +105,24 @@ pub async fn dependency_versions(
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let config_dir = host.config_dir.clone();
let cancel = host.begin_project_read_request(window, request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
if snap.large || snap.read_only {
return Ok(Vec::new());
}
if snap.path.file_name().is_some_and(|name| name == "go.mod") {
let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else {
return Ok(Vec::new());
};
project.require_trust()?;
let source = go_source(&project, &config_dir, snap.path.as_ref())?;
let versions = cache.versions(&source, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
@@ -0,0 +1,107 @@
use super::*;
use ide_workspace::dependencies::VersionCache;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
fn source(dir: &Path, script: &str) -> GoModules {
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let go = bin.join("go");
std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap();
GoModules {
root: dir.to_owned(),
env: BTreeMap::from([
("PATH".into(), bin.to_string_lossy().into_owned()),
("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()),
("GOPROXY".into(), "https://user:[email protected]".into()),
("GOPRIVATE".into(), "private.example/token".into()),
("GOWORK".into(), "broken.work".into()),
("GOFLAGS".into(), "-mod=vendor".into()),
]),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() {
let dir = tempfile::tempdir().unwrap();
let source = source(
dir.path(),
r#"set -eu
[ "$PWD" != "$PROJECT_ROOT" ]
[ "$GOPROXY" = 'https://user:[email protected]' ]
[ "$GOPRIVATE" = 'private.example/token' ]
[ "$GOWORK" = off ]
[ "$GOFLAGS" = '' ]
[ "$GO111MODULE" = on ]
[ "$GIT_TERMINAL_PROMPT" = 0 ]
[ "$1 $2 $3 $4" = 'list -m -versions -f' ]
[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ]
[ "$6 $7" = '-- example.com/Org/Lib/v2' ]
printf 'v2.9.0\nv2.10.0\n'"#,
);
let key = source.cache_key("example.com/Org/Lib/v2").unwrap();
assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap());
let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() };
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
other.root = source.root.clone();
other.env.insert("GOPROXY".into(), "https://other.example".into());
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert_eq!(versions, ["v2.10.0", "v2.9.0"]);
let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect();
assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter");
assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json"));
let cached = std::fs::read_to_string(entries[0].path()).unwrap();
for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] {
assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_go_queries_are_silent_and_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1");
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert!(versions.is_empty());
assert!(!disk.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() {
for cancel_parent in [false, true] {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "exec /bin/sleep 60");
let cancel = CancellationToken::new();
let query = cancel.clone();
let started = Instant::now();
let work = tokio::task::spawn_blocking(move || {
VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query)
});
if cancel_parent {
tokio::time::sleep(Duration::from_millis(150)).await;
cancel.cancel();
assert!(work.await.unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(3));
} else {
assert!(work.await.unwrap().unwrap().is_empty());
assert!(started.elapsed() >= Duration::from_secs(10));
assert!(started.elapsed() < Duration::from_secs(15));
assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller");
}
}
}
+18 -9
View File
@@ -188,7 +188,7 @@ pub(super) struct Shared {
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
/// plugin → files it shows problems for, withdrawn when it stops.
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
@@ -331,7 +331,7 @@ impl Shared {
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
}
}
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
if subscriptions && self.subscriptions.lock().remove(plugin) {
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
@@ -1166,13 +1166,22 @@ impl HostApi for PluginApi {
})
}
fn subscribe(&self, kind: EventKind, on: bool) {
let changed = {
let mut subs = self.shared.subscriptions.lock();
let set = subs.entry(self.id().to_owned()).or_default();
if on { set.insert(kind) } else { set.remove(&kind) }
};
if changed {
fn subscribe(&self, kind: EventKind) {
if self.shared.subscriptions.lock().set(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError> {
let filter = super::event_paths::PathFilter::new(kind, globs)?;
if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
Ok(())
}
fn unsubscribe(&self, kind: &str) {
if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
path::Path,
};
#[derive(Clone, Default)]
pub(super) struct Subscriptions {
pub kinds: HashMap<String, BTreeSet<EventKind>>,
pub paths: HashMap<String, HashMap<String, PathFilter>>,
}
#[derive(Clone)]
pub(super) struct PathFilter {
pub globs: Vec<String>,
matchers: Vec<GlobMatcher>,
}
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved") {
return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into()));
}
if globs.len() > 64 {
return Err(HostError::Invalid("at most 64 event path globs".into()));
}
let mut matchers = Vec::new();
for input in &globs {
let glob = input.trim_start_matches("./");
if glob.is_empty()
|| input.len() > 1024
|| glob.starts_with('/')
|| glob.contains('\\')
|| glob.contains(':')
|| glob.chars().any(char::is_control)
|| glob.split('/').any(|c| c == "..")
{
return Err(HostError::Invalid(
"event globs must be project-relative paths, at most 1024 bytes each".into(),
));
}
matchers.push(
GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))?
.compile_matcher(),
);
}
Ok(Self { globs, matchers })
}
pub fn matches(&self, root: &Path, path: &Path) -> bool {
let path = ide_plugin::resolve_real(path);
let root = ide_plugin::resolve_real(root);
path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative)))
}
}
impl Subscriptions {
pub fn remove(&mut self, plugin: &str) -> bool {
self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some()
}
/// Subscribes to every event of `kind`, dropping its path filter.
pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some());
self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed
}
pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool {
let mut changed = false;
if let Some(kind) = EventKind::parse(kind) {
changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind);
}
let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone());
previous.is_none_or(|old| old.globs != filter.globs) | changed
}
pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some());
let off = EventKind::parse(kind)
.is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind)));
removed | off
}
}
impl PluginHost {
pub(super) fn filter_path_events(
&self,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let workspace = self.shared.workspace();
events
.iter()
.filter(|event| {
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
_ => return true,
};
let Some(filter) = filters.get(event.kind().name()) else { return true };
if !path.is_absolute() {
return false;
}
let Some(workspace) = &workspace else { return false };
let root = match client {
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
};
let Some(root) = root else { return false };
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return false;
}
filter.matches(&root, path)
})
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_validation_is_bounded_and_project_relative() {
for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] {
assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}");
}
assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err());
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
}
#[test]
fn matchers_use_or_semantics_and_component_boundaries() {
let root = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(root.path()).unwrap();
let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap();
for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] {
assert!(filter.matches(&root, &root.join(path)), "{path}");
}
for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] {
assert!(!filter.matches(&root, &root.join(path)), "{path}");
}
assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml")));
assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a")));
}
#[test]
fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() {
let mut subs = Subscriptions::default();
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap());
assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened));
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap());
assert!(subs.paths["plugin"]["file-opened"].globs.is_empty());
subs.set("plugin", EventKind::FileOpened);
assert!(subs.paths["plugin"].is_empty());
subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap());
assert!(subs.unsubscribe("plugin", "file-saved"));
assert!(subs.remove("plugin"));
assert!(subs.paths.is_empty() && subs.kinds.is_empty());
}
}
+16 -2
View File
@@ -18,6 +18,7 @@
mod api;
pub mod commit;
pub mod endpoints;
mod event_paths;
mod hover;
mod icon_extensions;
mod icon_patterns;
@@ -1450,7 +1451,7 @@ impl PluginHost {
let mut out = BTreeSet::new();
for p in self.plugins.read().values() {
for kind in EventKind::ALL {
if self.wants(p, kind, subs.get(p.id())) {
if self.wants(p, kind, subs.kinds.get(p.id())) {
out.insert(kind);
}
}
@@ -1483,7 +1484,8 @@ impl PluginHost {
let mut queues = self.queues.lock();
for p in plugins {
let wanted: Vec<WorkbenchEvent> =
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect();
let wanted = self.filter_path_events(&p, client, &wanted, &subs);
if wanted.is_empty() {
continue;
}
@@ -1563,6 +1565,18 @@ impl PluginHost {
/// Returns whether the plugin was activated.
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
let Ok(plugin) = self.get(id) else { return false };
let subs = self.shared.subscriptions.lock().clone();
let events: Vec<_> = self
.filter_path_events(&plugin, client.as_deref(), &events, &subs)
.into_iter()
.filter(|event| {
!matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_))
|| self.wants(&plugin, event.kind(), subs.kinds.get(id))
})
.collect();
if events.is_empty() {
return false;
}
if !plugin.meta.lock().pending.is_empty() {
return false;
}
+122 -1
View File
@@ -19,6 +19,7 @@ type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
/// What plugins see of the test project: real `Documents`, as in the IDE.
struct TestWorkspace {
root: PathBuf,
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
trusted: bool,
/// (producer, path) → published diagnostics.
@@ -35,6 +36,7 @@ impl TestWorkspace {
);
Arc::new(Self {
root: root.to_path_buf(),
other_roots: Mutex::default(),
documents: Arc::new(documents),
trusted: true,
diagnostics: Mutex::default(),
@@ -67,7 +69,7 @@ impl TestWorkspace {
impl PluginWorkspace for TestWorkspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.root.clone()]
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.documents.text_of(path)
@@ -1653,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
f.install(&official("git-toolbox"));
let untrusted = Arc::new(TestWorkspace {
root: f.project.clone(),
other_roots: Mutex::default(),
documents: f.workspace.documents.clone(),
trusted: false,
diagnostics: Mutex::default(),
@@ -3565,3 +3568,121 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab
assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. })));
}
}
#[test]
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
let f = fixture(Limits::default());
f.install(&sample("tables"));
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
assert_eq!(
fields[2].value,
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
);
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
for (event, status) in [
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
] {
f.take_events();
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
}
}
#[test]
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
f.install(&sample("path-events"));
f.run("test.path-events", "count", None).unwrap();
let matched = to_ipc(&f.project.join("config/app.yaml"));
let other = to_ipc(&f.project.join("src/main.rs"));
let deliver = |events| {
f.host.post_events(Some("main"), events);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
};
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("test.path-events"), PluginStatus::Inactive);
let activations = f.host.info_of("test.path-events").unwrap().stats.activations;
deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]);
assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2"));
assert!(f.run("test.path-events", "filter", Some("../*")).is_err());
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"invalid filter preserves previous one"
);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]);
f.run("test.path-events", "filter", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"queued event uses current filter"
);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3"));
f.run("test.path-events", "all", None).unwrap();
deliver(vec![WorkbenchEvent::FileOpened(other.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]);
f.run("test.path-events", "off", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
deliver(vec![WorkbenchEvent::FileOpened(other)]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.set_enabled("test.path-events", false).unwrap();
assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind()));
}
#[test]
fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() {
let f = fixture(Limits::default());
f.install(&sample("path-events"));
let path = f.project.join("src/notes.txt");
assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project)));
assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None);
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(),
None
);
let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() };
let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, to_ipc(&f.project));
let nested = f.project.join("nested");
std::fs::create_dir(&nested).unwrap();
f.workspace.other_roots.lock().push(nested.clone());
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(),
None
);
}
f.host.set_capability("test.path-events", "project.read", false).unwrap();
assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read"));
f.host.set_capability("test.path-events", "events", false).unwrap();
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
}
+1 -1
View File
@@ -346,7 +346,7 @@ impl Drop for KillOnDrop {
/// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so
/// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else
/// is kept as given.
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
let path = std::path::Path::new(command);
if path.components().count() > 1 || path.is_absolute() {
return path.to_path_buf();
+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, resolve_new_in_root};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use types::*;
pub use wasmtime::component::Component;
+77 -3
View File
@@ -110,6 +110,17 @@ pub trait HostApi: Send + Sync {
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// The most specific open project containing a document (including a new file).
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
if !path.is_absolute() {
return None;
}
let real = crate::resolve_real(path);
self.projects()
.into_iter()
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
}
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
@@ -166,7 +177,11 @@ pub trait HostApi: Send + Sync {
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError>;
fn subscribe(&self, kind: EventKind, on: bool);
/// Delivers every event of `kind`, dropping its path filter.
fn subscribe(&self, kind: EventKind);
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
fn unsubscribe(&self, kind: &str);
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
@@ -1604,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
"readonly-text" => {
#[derive(serde::Deserialize)]
struct ReadonlyText {
text: String,
#[serde(default)]
multiline: bool,
}
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
FieldValue::ReadonlyText {
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
multiline: value.multiline,
}
}
_ => return None,
},
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
@@ -1658,6 +1688,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
ViewEvent::TableSelect { row } => {
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
}
ViewEvent::TableActivate { row } => {
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
}
// A sortable table's header click, as an `other` view-event (plugins/
// README.md §10): the SDKs decode the kinds they know.
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
@@ -1671,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[test]
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
let fields = [
("label", r#""Title \"quoted\"""#),
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
("readonly-text", r#"{"text":"author"}"#),
("label", "{}"),
("readonly-text", r#"{"text":false}"#),
("future", "null"),
]
.into_iter()
.enumerate()
.map(|(i, (kind, data))| wit_vt::Field {
key: i.to_string(),
label: "Field".into(),
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
})
.collect();
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
let ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
}
#[test]
fn table_row_events_leave_through_released_extension_case() {
for (event, kind) in [
(ViewEvent::TableSelect { row: 51 }, "table-select"),
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
] {
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
assert_eq!(extension.kind, kind);
assert_eq!(extension.data, r#"{"row":51}"#);
}
}
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
+1 -1
View File
@@ -21,7 +21,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
/// 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 {
pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
+4
View File
@@ -527,6 +527,8 @@ pub struct ViewListItem {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum FieldValue {
Label(String),
ReadonlyText { text: String, multiline: bool },
Text(String),
Multiline(String),
Checkbox(bool),
@@ -644,6 +646,8 @@ pub enum ViewEvent {
// A sortable table's header click: the column and the direction (leaves
// as an `other` view-event, kind `sort`). See `TableSort`.
Sort { column: u32, descending: bool },
TableSelect { row: u32 },
TableActivate { row: u32 },
}
/// A plugin's HTTP route declaration in a project.
+13 -4
View File
@@ -460,6 +460,10 @@ impl workspace::Host for StoreData {
Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit))
}
fn project_of(&mut self, path: String) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
}
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -758,14 +762,19 @@ impl events::Host for StoreData {
{
return Ok(Err(e));
}
self.host.subscribe(kind, true);
self.host.subscribe(kind);
Ok(Ok(()))
}
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
if let Some(kind) = EventKind::parse(&kind) {
self.host.subscribe(kind, false);
fn subscribe_paths(&mut self, kind: String, globs: Vec<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::Events) {
return Ok(Err(e));
}
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
}
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
self.host.unsubscribe(&kind);
Ok(())
}
}
@@ -1 +1 @@
f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6
73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b
@@ -1 +1 @@
344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b
4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90
Binary file not shown.
@@ -1 +1 @@
47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b
83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1
@@ -1 +1 @@
c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6
bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0
@@ -1 +1 @@
58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a
1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef
@@ -1 +1 @@
56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3
17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0
Binary file not shown.
@@ -1 +1 @@
ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a
85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e
@@ -1 +1 @@
89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540
ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6
+333
View File
@@ -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-path-events"
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
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-path-events"
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 @@
66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d
@@ -0,0 +1,34 @@
id = "test.path-events"
name = "Path event fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["storage"]
optional = ["events", "project.read"]
[[contributes.providers]]
kinds = ["hover"]
languages = ["text", "plaintext"]
[[contributes.commands]]
id = "project"
title = "project"
[[contributes.commands]]
id = "filter"
title = "filter"
[[contributes.commands]]
id = "all"
title = "all"
[[contributes.commands]]
id = "off"
title = "off"
[[contributes.commands]]
id = "count"
title = "count"
+47
View File
@@ -0,0 +1,47 @@
use openidea_plugin::hover::{Document, Hover, HoverProvider};
use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn activate(&mut self, _: &Context) -> Result<(), Error> {
// Persist the chosen filter so reactivation does not overwrite it.
let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into());
let globs: Vec<_> = glob.lines().collect();
let _ = host::subscribe_paths("file-opened", &globs);
let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]);
Ok(())
}
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(),
"filter" => {
let glob = arg.unwrap_or_default();
host::subscribe_paths("file-opened", &glob.lines().collect::<Vec<_>>())?;
host::storage::set("filter", glob)?;
Ok(None)
}
"all" => {
host::subscribe(EventKind::FileOpened)?;
Ok(None)
}
"off" => {
host::unsubscribe(EventKind::FileOpened);
Ok(None)
}
"count" => Ok(host::storage::get("count")?),
_ => Err(Error::NotFound(id.into())),
}
}
fn handle_events(&mut self, events: &[Event], _: &Invocation) {
let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
host::storage::set("count", &(count + events.len()).to_string()).unwrap();
}
}
impl HoverProvider for Fixture {
fn hover(&mut self, doc: &Document, _: Position) -> Option<Hover> {
Some(Hover::new(host::project_of(&doc.path)?.root().ok()?))
}
}
register_plugin!(Fixture, hover);
@@ -1 +1 @@
284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c
98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018
@@ -1 +1 @@
243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504
33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787
Binary file not shown.
@@ -1 +1 @@
4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979
92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615
+17 -2
View File
@@ -7,12 +7,27 @@ name = "Tables"
version = "0.1.0"
description = "Test fixture: a sortable table the plugin sorts itself."
authors = ["OpenIDEA contributors"]
engines.openidea = "^1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-view:tables"]
activation = ["on-view:tables", "on-view:fields", "on-command:inspect"]
[[contributes.views]]
id = "tables"
title = "Tables"
icon = "plugin"
location = "right"
[[contributes.views]]
id = "fields"
title = "Read-only Fields"
icon = "plugin"
location = "right"
[[contributes.commands]]
id = "inspect"
title = "Inspect Row"
[[contributes.menus]]
menu = "viewItem.context"
view = "tables"
command = "inspect"
+45 -3
View File
@@ -4,7 +4,7 @@
//! `sort`), and the rows render in the order the plugin was asked for.
use openidea_plugin::host;
use openidea_plugin::views::{Body, Table, TableSort, View, ViewEvent, Viewport, Views};
use openidea_plugin::views::{Body, Field, FieldValue, Table, TableSort, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
pub const VIEW: &str = "tables";
@@ -15,6 +15,8 @@ const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("tom
#[derive(Default)]
pub struct Tables {
sort: Option<TableSort>,
status: Option<String>,
note: Option<String>,
}
impl Tables {
@@ -34,13 +36,34 @@ impl Tables {
}
impl Plugin for Tables {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
if id == "inspect" {
self.status = Some(format!("context:{}", arg.unwrap_or("")));
host::refresh_view(VIEW);
return Ok(self.status.clone());
}
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for Tables {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view == "fields" {
return Ok(View::new(Body::Form(vec![
Field::new("title", "Title", FieldValue::label("Merge request #42")),
Field::new("author", "Author", FieldValue::readonly_text("Ada Lovelace", false)),
Field::new(
"description",
"Description",
FieldValue::readonly_text("Ready for review\nAll checks passed", true),
),
Field::new(
"editable",
"Editable note",
FieldValue::Text(self.note.clone().unwrap_or_else(|| "Edit me".into())),
),
])));
}
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
@@ -51,13 +74,32 @@ impl Views for Tables {
rows: self.rows(),
editable: false,
};
Ok(View::new(Body::sortable_table(&table, self.sort)))
let mut view = View::new(Body::sortable_table(&table, self.sort));
view.status = self.status.clone();
Ok(view)
}
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
if view == "fields" {
if let ViewEvent::Field(key, value) = event {
if key == "editable" {
self.note = Some(value);
host::refresh_view("fields");
}
}
return Ok(());
}
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
if let Some(row) = event.table_select() {
self.status = Some(format!("select:{row}"));
host::refresh_view(VIEW);
}
if let Some(row) = event.table_activate() {
self.status = Some(format!("activate:{row}"));
host::refresh_view(VIEW);
}
// The host may have encoded the click for a newer SDK: the typed
// helper reads the `other` kind `sort` this SDK knows.
if let Some(sort) = event.sort() {
+1 -1
View File
@@ -228,7 +228,7 @@ mod command_tests {
}
}
pub fn debug_environment(
pub fn configure_environment(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
env: &mut std::collections::BTreeMap<String, String>,
+4 -2
View File
@@ -558,13 +558,15 @@ pub fn available_shells() -> Vec<String> {
/// argument-less) POSIX shell is started as a login shell, like Terminal.app,
/// the Git Bash shortcut and IDEA: login start-up is what loads the completions
/// and prompts users expect (`/etc/profile`, Git for Windows' git-completion).
/// `OPENIDEA_SKIP_SHELL_ENV` disables the implicit login argument; explicit
/// arguments still take precedence, and interactive startup files still run.
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell);
if !args.is_empty() {
return (shell, args.to_vec());
}
let login =
matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh");
let login = std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_none()
&& matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh");
let args = if login { vec!["-l".to_owned()] } else { Vec::new() };
(shell, args)
}
@@ -39,11 +39,11 @@ impl VersionCache {
package: &str,
cancel: &CancellationToken,
) -> IdeResult<Vec<String>> {
let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b));
if !safe(source.id()) || !safe(package) || package.len() > 64 {
let Some(key) = source.cache_key(package).filter(|key| {
!key.is_empty() && key.len() <= 128 && key.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b))
}) else {
return Ok(Vec::new());
}
let key = format!("{}-{}", source.id(), package.to_ascii_lowercase());
};
let slot = self.entries.lock().entry(key.clone()).or_default().clone();
let mut cached = loop {
if cancel.is_cancelled() {
+118
View File
@@ -0,0 +1,118 @@
use super::ManifestDependency;
fn module_path(path: &str) -> bool {
let Some(domain) = path.split('/').next() else { return false };
path.len() <= 512
&& domain.contains('.')
&& !domain.starts_with(['.', '-'])
&& path.split('/').all(|part| {
!part.is_empty()
&& !part.ends_with('.')
&& part.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c))
})
}
/// Finds the version at a UTF-16 offset, including incomplete require lines.
pub fn dependency_at(text: &str, offset: u32) -> Option<ManifestDependency> {
let mut block = None;
let mut start = 0;
for line in text.split_inclusive('\n') {
let line_len = line.encode_utf16().count() as u32;
let code = line.split("//").next()?.trim_end_matches(['\r', '\n']);
let trimmed = code.trim();
if offset < start + line_len || offset == start + line_len && !line.ends_with('\n') {
let body = match block {
Some("require") => code.trim_start(),
None => code.trim_start().strip_prefix("require")?.strip_prefix([' ', '\t'])?.trim_start(),
_ => return None,
};
let (path, rest) = body.split_once([' ', '\t'])?;
let package = path.trim_matches('"');
if !module_path(package) {
return None;
}
let version = rest.trim_start();
let token = version.split_ascii_whitespace().next().unwrap_or_default();
let from = start + code[..code.len() - version.len()].encode_utf16().count() as u32;
let (requirement, from) = match token.strip_prefix('"') {
Some(quoted) => (quoted.strip_suffix('"').unwrap_or(quoted), from + 1),
None => (token, from),
};
let to = from + requirement.encode_utf16().count() as u32;
return (from <= offset && offset <= to).then(|| ManifestDependency {
package: package.to_owned(),
requirement: requirement.to_owned(),
from,
to,
});
}
if trimmed == ")" {
block = None;
} else if block.is_none() {
block = trimmed.strip_suffix('(').map(str::trim);
}
start += line_len;
}
None
}
/// Go lists versions in ascending semantic order. Ignore tool output unrelated to versions.
pub fn listed_versions(output: &str) -> Vec<String> {
output
.lines()
.rev()
.map(str::trim)
.filter(|line| line.strip_prefix('v').is_some_and(|version| semver::Version::parse(version).is_ok()))
.map(str::to_owned)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completes_blank_partial_and_quoted_require_versions() {
for (text, package, version) in [
("module example.com/m\nrequire golang.org/x/text |", "golang.org/x/text", ""),
(
"// 中文 😀\r\nrequire (\r\n\tgithub.com/Org/Lib/v2 v2.|1.0 // indirect\r\n)\r\n",
"github.com/Org/Lib/v2",
"v2.1.0",
),
("require \"rsc.io/quote\" \"v1.|2.0\"", "rsc.io/quote", "v1.2.0"),
] {
let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32;
let text = text.replace('|', "");
let item = dependency_at(&text, offset).unwrap();
assert_eq!(item.package, package);
assert_eq!(item.requirement, version);
let units: Vec<_> = text.encode_utf16().collect();
assert_eq!(String::from_utf16(&units[item.from as usize..item.to as usize]).unwrap(), version);
}
}
#[test]
fn ignores_comments_other_directives_and_non_module_arguments() {
for text in [
"// require rsc.io/quote v|",
"require rsc.io/quote v1.2.0 // in|direct",
"require rsc.io/qu|ote v1.2.0",
"replace (\n\trsc.io/quote v|\n)",
"require ../quote v|",
"require -flag.example v|",
"require rsc.io/quote@latest v|",
] {
let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32;
assert!(dependency_at(&text.replace('|', ""), offset).is_none(), "{text}");
}
}
#[test]
fn keeps_go_version_spelling_and_prefers_newer_versions() {
assert_eq!(
listed_versions("go: notice\nv1.9.0\nv1.10.0-rc.1\nv2.0.0+incompatible\n"),
["v2.0.0+incompatible", "v1.10.0-rc.1", "v1.9.0"]
);
}
}
@@ -1,6 +1,7 @@
//! Manifest dependencies and repository versions, independent of editor and transport.
mod cache;
pub mod cargo;
pub mod go;
pub use cache::VersionCache;
use ide_core::{CancellationToken, IdeResult};
@@ -11,6 +12,12 @@ use specta::Type;
pub trait DependencyVersionSource: Send + Sync {
/// A filesystem-safe, stable repository identifier.
fn id(&self) -> &'static str;
/// Override for case-sensitive module paths or project-specific repositories.
fn cache_key(&self, package: &str) -> Option<String> {
let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b));
let id = self.id();
(safe(id) && safe(package) && package.len() <= 64).then(|| format!("{id}-{}", package.to_ascii_lowercase()))
}
/// Non-yanked versions in descending semantic order.
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>>;
}
+41
View File
@@ -419,6 +419,27 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
API 1.3 adds `host::subscribe_paths("file-opened", &["config/*.yaml"])`
(`events.subscribe-paths`). The host matches project-relative globs before
queueing an event or waking an idle plugin. `file-saved` works the same way.
Patterns use `/`, `*` stays within a directory, and `**` crosses
directories. Up to 64 patterns of 1024 bytes each are accepted; absolute paths,
parent traversal and malformed patterns fail without replacing the old filter.
Register filters in `activate`, using a startup or command activation to install
them before observing events. Patterns are ORed; another call replaces the same
kind's filter, an empty list pauses it, `subscribe(kind)` restores all paths,
and `unsubscribe(kind)` removes it. Filters survive idle reclaim and disappear
on disable, uninstall or reload. Each window's events use its current project;
files outside that project do not match.
Provider calls can use `host::project_of(&doc.path)` (`workspace.project-of`)
without an invocation project or enumerating every open project. It returns the
deepest open project containing the absolute path, or `None` outside open roots.
Symlinks and missing file tails are resolved before checking containment.
Obtaining a handle grants no read access: `Project::root` and file operations
still require `project.read`.
### Commits
A plugin can work on the commit the user is preparing, in the Commit tool
@@ -618,6 +639,26 @@ For a table the user can sort by clicking a header, build it with
column headers clickable and asks for a column and direction with
`ViewEvent::sort()` — sort your rows and render again; the sort you report
shows as an arrow in the header.
API 1.3 adds read-only form values without changing the released field record:
`FieldValue::label(text)` uses `other { kind: "label", data }`, where `data`
is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind
`readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag
defaults to false). Labels render as plain selectable values; read-only text
keeps the single-line or multiline text control and never sends field events.
Table row clicks and Space send `other` kind `table-select`; double-click
and Enter send `table-activate`, both with JSON `{ "row": 0 }`.
`ViewEvent::table_select()` and `table_activate()` decode the absolute,
zero-based index, including `first_row` for paged tables. These work for both
plain tables and the same `other` table shape used by sortable tables above;
no new table shape is needed. Editable cells still double-click to edit;
activate their row with Enter on the focused row or double-click its row number.
`viewItem.context` menus also work on table rows: the command argument and
`viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables
keep their insert/delete entries alongside contributed commands. After sorting
or changing data, plugins resolve row indices against their current row order.
Declare `engines.openidea = "^1.3"` when relying on these additions.
Bodies, field types and view events added by later APIs travel as `Other {
kind, data }`: an IDE that doesn't know a body says it needs updating, and
leaves out a field type it doesn't know.
+1 -1
View File
@@ -1 +1 @@
10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316
abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c
+1 -1
View File
@@ -1 +1 @@
9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf
7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a
d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99
+1 -1
View File
@@ -1 +1 @@
c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696
2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a
+1 -1
View File
@@ -1 +1 @@
65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892
4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb
+1 -1
View File
@@ -1 +1 @@
d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852
d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748
+1 -1
View File
@@ -1 +1 @@
d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809
582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584
+1 -1
View File
@@ -1 +1 @@
a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a
3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268
2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c
+1 -1
View File
@@ -1 +1 @@
15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949
dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47
+1 -1
View File
@@ -1 +1 @@
11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb
8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340
+21
View File
@@ -182,6 +182,10 @@ mod imp {
wit::workspace::projects().map_err(error)
}
pub fn project_of(path: &str) -> Option<ProjectRes> {
wit::workspace::project_of(path)
}
pub fn project_root(p: &ProjectRes) -> Result<String, Error> {
p.root().map_err(error)
}
@@ -278,6 +282,10 @@ mod imp {
}
}
pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> {
wit::events::subscribe_paths(kind, &globs.iter().map(|g| (*g).to_owned()).collect::<Vec<_>>()).map_err(error)
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(error)
}
@@ -621,6 +629,12 @@ pub fn projects() -> Result<Vec<Project>, Error> {
Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect())
}
/// The deepest open project containing an absolute document path, including in providers.
/// Obtaining the handle needs no capability; reading it still needs `project.read`.
pub fn project_of(path: &str) -> Option<Project> {
imp::project_of(path).map(|inner| Project { inner })
}
/// A file open in an editor.
pub struct TextEditor {
inner: imp::EditorRes,
@@ -790,6 +804,13 @@ pub fn locale() -> String {
imp::locale()
}
/// Subscribe to project-relative path globs for file-opened or file-saved.
/// Replaces the previous filter. Empty globs pause delivery; `subscribe` restores all paths.
/// Requires `events`. Register during activation before the events you want to observe.
pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> {
imp::subscribe_paths(kind, globs)
}
/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of
/// the session (also after the instance is reclaimed: an event activates the
/// plugin again). Needs `events` (`events.input` for `ActionPerformed`).
+95 -1
View File
@@ -72,6 +72,7 @@ struct State {
context: BTreeMap<String, ContextValue>,
storage: BTreeMap<String, String>,
subscriptions: BTreeSet<EventKind>,
path_subscriptions: BTreeMap<String, Vec<String>>,
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
@@ -230,6 +231,10 @@ impl MockHost {
self.state.borrow().storage.clone()
}
pub fn path_subscriptions(&self) -> BTreeMap<String, Vec<String>> {
self.state.borrow().path_subscriptions.clone()
}
pub fn subscriptions(&self) -> BTreeSet<EventKind> {
self.state.borrow().subscriptions.clone()
}
@@ -613,6 +618,27 @@ pub mod current {
})
}
pub fn project_of(path: &str) -> Option<ProjectRes> {
let path = path.replace('\\', "/");
let absolute =
path.starts_with('/') || (path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/'));
if !absolute || path.split('/').any(|part| part == "..") {
return None;
}
with(|s| {
s.root
.iter()
.chain(&s.other_projects)
.filter(|root| {
let root = root.replace('\\', "/");
path == root
|| path.strip_prefix(root.trim_end_matches('/')).is_some_and(|rest| rest.starts_with('/'))
})
.max_by_key(|root| root.replace('\\', "/").split('/').count())
.map(|root| MockProject { root: root.clone() })
})
}
pub fn project_root(p: &ProjectRes) -> Result<String, Error> {
with(|s| {
require(s, "project.read")?;
@@ -797,6 +823,38 @@ pub mod current {
})
}
pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> {
with(|s| {
require(s, "events")?;
if !matches!(kind, "file-opened" | "file-saved")
|| globs.len() > 64
|| globs.iter().any(|g| {
let relative = g.trim_start_matches("./");
relative.is_empty()
|| g.len() > 1024
|| relative.starts_with('/')
|| g.contains('\\')
|| g.contains(':')
|| g.chars().any(char::is_control)
|| g.split('/').any(|p| p == "..")
})
{
return Err(Error::Invalid("invalid event path filter".into()));
}
match kind {
"file-opened" => {
s.subscriptions.insert(EventKind::FileOpened);
}
"file-saved" => {
s.subscriptions.insert(EventKind::FileSaved);
}
_ => {}
}
s.path_subscriptions.insert(kind.to_owned(), globs.iter().map(|g| (*g).to_owned()).collect());
Ok(())
})
}
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| {
let cap = match kind {
@@ -807,13 +865,17 @@ pub mod current {
if let Some(cap) = cap {
require(s, cap)?;
}
s.path_subscriptions.remove(kind.name());
s.subscriptions.insert(kind);
Ok(())
})
}
pub fn unsubscribe(kind: EventKind) {
with(|s| s.subscriptions.remove(&kind));
with(|s| {
s.path_subscriptions.remove(kind.name());
s.subscriptions.remove(&kind);
});
}
pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> {
@@ -965,6 +1027,38 @@ mod tests {
use super::*;
use crate::host;
#[test]
fn path_subscriptions_replace_pause_and_restore() {
let mock = MockHost::install();
host::subscribe_paths("file-opened", &["config/*.yaml"]).unwrap();
assert_eq!(mock.path_subscriptions()["file-opened"], ["config/*.yaml"]);
host::subscribe_paths("file-opened", &[]).unwrap();
assert!(mock.path_subscriptions()["file-opened"].is_empty());
assert!(host::subscribe_paths("file-opened", &["../secret"]).is_err());
host::subscribe(EventKind::FileOpened).unwrap();
assert!(mock.path_subscriptions().is_empty());
host::subscribe_paths("file-saved", &["**/*.rs"]).unwrap();
host::unsubscribe(EventKind::FileSaved);
assert!(mock.path_subscriptions().is_empty());
let _denied = MockHost::install().with_capabilities(&[]);
assert!(matches!(host::subscribe_paths("file-opened", &[]), Err(Error::Denied(_))));
}
#[test]
fn project_lookup_uses_deepest_root_without_granting_read_access() {
let mock = MockHost::install();
mock.add_project("/project/nested");
assert_eq!(host::project_of("/project/nested/new.txt").unwrap().root().unwrap(), "/project/nested");
assert!(host::project_of("/project-other/file").is_none());
assert!(host::project_of("relative/file").is_none());
mock.add_project("C:/workspace");
assert_eq!(host::project_of(r"C:\workspace\file.txt").unwrap().root().unwrap(), "C:/workspace");
mock.add_project("//server/share");
assert_eq!(host::project_of(r"\\server\share\file.txt").unwrap().root().unwrap(), "//server/share");
let _denied = MockHost::install().with_capabilities(&[]);
assert!(matches!(host::project_of("/project/file").unwrap().root(), Err(Error::Denied(_))));
}
#[test]
fn invocations_carry_selected_paths() {
let host = MockHost::install();
+51
View File
@@ -125,6 +125,21 @@ pub enum FieldValue {
},
}
impl FieldValue {
/// A plain read-only value, carried by `other` kind `label` (API 1.3).
pub fn label(text: &str) -> Self {
Self::Other { kind: "label".into(), data: format!("\"{}\"", crate::text::escape_json(text)) }
}
/// Selectable text with no field-change events (API 1.3).
pub fn readonly_text(text: &str, multiline: bool) -> Self {
Self::Other {
kind: "readonly-text".into(),
data: format!(r#"{{"text":"{}","multiline":{multiline}}}"#, crate::text::escape_json(text)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Field {
pub key: String,
@@ -273,6 +288,21 @@ pub enum ViewEvent {
}
impl ViewEvent {
/// The absolute zero-based row selected in a table (API 1.3).
pub fn table_select(&self) -> Option<u32> {
self.table_row("table-select")
}
/// The absolute zero-based row activated in a table (API 1.3).
pub fn table_activate(&self) -> Option<u32> {
self.table_row("table-activate")
}
fn table_row(&self, expected: &str) -> Option<u32> {
let Self::Other { kind, data } = self else { return None };
(kind == expected).then(|| json_number(data, "row")).flatten()
}
/// The click on a sortable table's header ([`Body::sortable_table`]): the
/// column and the direction to order it by (`other` view-event, kind
/// `sort`; the first click asks ascending, a second on the same column
@@ -342,6 +372,27 @@ pub trait Views {
mod tests {
use super::*;
#[test]
fn readonly_fields_escape_text_without_changing_released_variants() {
assert_eq!(FieldValue::label("a\"\n"), FieldValue::Other { kind: "label".into(), data: r#""a\"\n""#.into() });
assert_eq!(
FieldValue::readonly_text("a\nβ", true),
FieldValue::Other { kind: "readonly-text".into(), data: r#"{"text":"a\nβ","multiline":true}"#.into() }
);
}
#[test]
fn table_row_events_only_decode_their_own_valid_payloads() {
let event = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() };
assert_eq!(event("table-select", r#"{"row":51}"#).table_select(), Some(51));
assert_eq!(event("table-activate", r#"{"row":0}"#).table_activate(), Some(0));
for data in ["{}", r#"{"row":-1}"#, r#"{"row":4294967296}"#, r#"{"row":"1"}"#] {
assert_eq!(event("table-select", data).table_select(), None);
}
assert_eq!(event("select", r#"{"row":1}"#).table_select(), None);
assert_eq!(event("table-select", r#"{"row":1}"#).table_activate(), None);
}
#[test]
fn a_sortable_table_becomes_the_other_table_body() {
let table = Table {
+4
View File
@@ -36,6 +36,10 @@ interface events {
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: string) -> result<_, error>;
/// Replaces this kind's subscription with project-relative globs (OR).
/// Empty globs pause delivery; `subscribe` restores unfiltered delivery.
/// Kinds: file-opened, file-saved. Requires `events`.
subscribe-paths: func(kind: string, globs: list<string>) -> result<_, error>;
unsubscribe: func(kind: string);
}
@@ -8,6 +8,10 @@ interface workspace {
/// `path` is an absolute directory path on the UI machine.
open-project: func(path: string, new-window: bool) -> result<_, error>;
/// The open project containing an absolute document path, or `none`.
/// Reading the returned project still requires `project.read`.
project-of: func(path: string) -> option<project>;
/// What a path inside a project is.
enum file-kind {
file,
+1
View File
@@ -431,6 +431,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window::window_toggle_fullscreen,
window::window_flash_taskbar,
window::window_set_titlebar_height,
window::window_is_fullscreen,
window_drag::window_start_drag,
window::window_take_launch,
window::window_close_context,
+14
View File
@@ -193,6 +193,20 @@ pub fn window_open_projects(
.collect())
}
/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window
/// group's full-screen Space.
#[tauri::command]
#[specta::specta]
pub fn window_is_fullscreen(window: WebviewWindow) -> bool {
#[cfg(target_os = "macos")]
{
use tauri::Manager;
crate::window_groups::in_fullscreen(window.app_handle(), &window)
}
#[cfg(not(target_os = "macos"))]
window.is_fullscreen().unwrap_or(false)
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CloseContext {
+1
View File
@@ -135,6 +135,7 @@ pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWin
if let Some(frame) = frame {
native.setFrame_display(frame, true);
}
target.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
})
}
+12 -6
View File
@@ -12,11 +12,12 @@ use objc2::{
runtime::ProtocolObject,
};
use objc2_app_kit::{
NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
NSWindowWillEnterFullScreenNotification,
NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification,
};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
use tauri::Manager;
/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar.
const COMPACT_MAX_HEIGHT: f64 = 45.0;
@@ -55,7 +56,7 @@ impl Drop for Observer {
struct Tracked {
state: Rc<Fullscreen>,
_observers: [Observer; 2],
_observers: [Observer; 3],
}
thread_local! {
@@ -71,7 +72,7 @@ fn apply(window: &NSWindow, state: &Fullscreen) {
}
}
fn track(window: &NSWindow) -> Tracked {
fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
let state = Rc::new(Fullscreen::default());
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
state.will_enter();
@@ -79,11 +80,14 @@ fn track(window: &NSWindow) -> Tracked {
let observe = |name, change: fn(&Fullscreen)| {
let weak = Weak::new(window);
let state = state.clone();
let app = app.clone();
let block = block2::RcBlock::new(move |_| {
change(&state);
if let Some(window) = weak.load() {
apply(&window, &state);
}
// The web title bar shows the window buttons itself in full screen.
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
});
let observer = unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
@@ -98,6 +102,7 @@ fn track(window: &NSWindow) -> Tracked {
let observers = unsafe {
[
observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter),
observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter),
observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit),
]
};
@@ -106,12 +111,13 @@ fn track(window: &NSWindow) -> Tracked {
pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> {
let label = window.label().to_owned();
let app = window.app_handle().clone();
// 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());
let state = WINDOWS
.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone());
if native.toolbar().is_none() {
let toolbar = NSToolbar::new(mtm);
native.setToolbar(Some(&toolbar));
+13 -3
View File
@@ -903,6 +903,11 @@ export const commands = {
windowFlashTaskbar: (flash: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("window_flash_taskbar", { flash })),
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
/**
* Whether this window fills the screen: in native full screen, or (macOS) shown in its window
* group's full-screen Space.
*/
windowIsFullscreen: () => __TAURI_INVOKE<boolean>("window_is_fullscreen"),
/**
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
*
@@ -3796,7 +3801,10 @@ export type FacetConfig = {
webRoot?: string,
};
export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: {
export type FieldValue = { kind: "label"; value: string } | { kind: "readonlyText"; value: {
text: string,
multiline: boolean,
} } | { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: {
options: string[],
selected: string,
} };
@@ -4488,7 +4496,9 @@ export type IdeError = {
export type IdeEvent =
/** Project window membership or selection changed. */
{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } |
{ type: "windowGroupsChanged" } |
/** A window entered or left full screen, itself or as a member of a full-screen window group. */
{ type: "windowFullscreenChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } |
/** Files or directories changed on disk (after debouncing, ignore rules applied). */
{ type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } |
/** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */
@@ -8129,7 +8139,7 @@ export type ViewButton = {
};
/** A user action in a view. */
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean };
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean } | { kind: "tableSelect"; row: number } | { kind: "tableActivate"; row: number };
export type ViewField = {
key: string,
+2 -2
View File
@@ -1,7 +1,7 @@
/** Built-in launch settings; each executable is staged independently until Apply. */
import { useCallback, useEffect, useState } from "react";
import type { AgentProvider } from "@/bindings";
import { tx, useTx } from "@/platform/i18n";
import { tx, useTxTemplate } from "@/platform/i18n";
import clsx from "clsx";
import { Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui";
import { useSettingsForm } from "@/workbench/contributions";
@@ -242,7 +242,7 @@ const formatEnv = (env: Record<string, string>) =>
/** Login hints are plain text with optional backtick-delimited command spans. */
function LoginHint({ text }: { text: string }) {
const translate = useTx();
const translate = useTxTemplate();
return translate(text)
.split(/(`[^`]+`)/g)
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
+6 -1
View File
@@ -58,6 +58,7 @@ import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { AgentReply } from "./AgentReply";
import { Markdown } from "./Markdown";
import { useAgentErrorText } from "./loginHint";
import {
branchLabel,
entriesKey,
@@ -866,12 +867,16 @@ const Entry = memo(function Entry({
return (
<div className={styles.error} role="alert">
<Icon name="error" size={14} />
<span>{entry.message}</span>
<AgentErrorText message={entry.message} />
</div>
);
}
});
function AgentErrorText({ message }: { message: string }) {
return <span>{useAgentErrorText(message)}</span>;
}
function UserMessage({
task,
entry,
+26
View File
@@ -0,0 +1,26 @@
import { renderHook } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { useAgentErrorText } from "./loginHint";
afterEach(() => _resetI18nForTests());
it.each([
["Run `cursor-agent login` in a terminal.", "在终端中运行 `cursor-agent login`。"],
["Run `'/registry path/cursor-agent' login` in a terminal.", "在终端中运行 `'/registry path/cursor-agent' login`。"],
[
"Run `& 'C:\\Agent Tools\\cursor-agent.cmd' login` in a terminal.",
"在终端中运行 `& 'C:\\Agent Tools\\cursor-agent.cmd' login`。",
],
["Run `'/registry/claude'` in a terminal and sign in.", "在终端中运行 `'/registry/claude'` 并登录。"],
])("translates sign-in guidance with its command intact: %s", async (hint, translated) => {
await setLocale("zh");
const { result } = renderHook(() => useAgentErrorText(`Cursor needs you to sign in. ${hint}`));
expect(result.current).toBe(`Cursor 需要你登录。${translated}`);
});
it("preserves external agent errors", async () => {
await setLocale("zh");
const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed"));
expect(result.current).toBe("Agent says: authentication failed");
});
+9
View File
@@ -0,0 +1,9 @@
import { useTx, useTxTemplate } from "@/platform/i18n";
/** Translate the IDE's sign-in guidance while preserving the agent's own errors. */
export function useAgentErrorText(message: string): string {
const tx = useTx();
const translate = useTxTemplate();
const auth = /^(.+) needs you to sign in\. (.+)$/s.exec(message);
return auth ? tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}` : message;
}
+37 -3
View File
@@ -46,14 +46,14 @@ afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
function editor(path = "/p/Cargo.toml", readOnly = false) {
function editor(path = "/p/Cargo.toml", readOnly = false, source = text) {
const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly };
const contribution = editorContributions().find((c) => c.id === "dependency-versions")!;
const view = new EditorView({
parent: document.body,
state: EditorState.create({
doc: text,
selection: { anchor: from },
doc: source,
selection: { anchor: Math.min(from, source.length) },
extensions: [lspDoc.of(ctx), contribution.extensions(ctx)],
}),
});
@@ -208,3 +208,37 @@ describe("Cargo dependency versions", () => {
expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" });
});
});
describe("Go module dependency versions", () => {
it("requests versions at the caret and replaces the entire version without running inspections", async () => {
const source = "module example.com/app\n\nrequire (\n\tgolang.org/x/text v0.1.0 // indirect\n)\n";
const from = source.indexOf("v0.1.0");
const to = from + "v0.1.0".length;
const view = editor("/p/go.mod", false, source);
await settle();
expect(dependencyVersions).not.toHaveBeenCalled();
dependencyVersions.mockResolvedValue({
status: "ok",
data: [
{
dependency: { package: "golang.org/x/text", requirement: "v0.1.0", from, to },
versions: ["v1.2.0", "v0.12.0", "v0.1.0"],
update: null,
},
],
});
const complete = view.state.languageDataAt<(ctx: CompletionContext) => Promise<unknown>>(
"openideaCompletion",
from,
)[0]!;
expect(await complete(new CompletionContext(view.state, from + 3, false, view))).toMatchObject({
from,
to,
options: [
{ label: "v0.12.0", apply: "v0.12.0" },
{ label: "v0.1.0", apply: "v0.1.0" },
],
});
expect(dependencyVersions).toHaveBeenCalledExactlyOnceWith(7, from + 3, expect.any(Number));
});
});
+21 -12
View File
@@ -168,12 +168,25 @@ function extension(doc: EditorDocContext): Extension {
},
};
});
return [
versionsField,
plugin,
intentionProviders.of((view) => {
const selection = view.state.selection.main;
return dependencyFixes(view, selection.from, selection.to);
}),
quickFixProviders.of(dependencyFixes),
versionCompletion(doc),
];
}
function versionCompletion(doc: EditorDocContext): Extension {
const completion = async (ctx: CompletionContext): Promise<CompletionResult | null> => {
if (!isTauri()) return null;
const id = nextRequestId();
const text = ctx.state.doc;
const root = useProject.getState().project?.root;
ctx.addEventListener("abort", () => void commands.cancelRequest(id));
ctx.addEventListener("abort", () => void commands.cancelRequest(id), { onDocChange: true });
try {
// The completion context is immutable; only use it while its document is current.
const view = ctx.view;
@@ -188,21 +201,17 @@ function extension(doc: EditorDocContext): Extension {
return null;
}
};
return [
versionsField,
plugin,
intentionProviders.of((view) => {
const selection = view.state.selection.main;
return dependencyFixes(view, selection.from, selection.to);
}),
quickFixProviders.of(dependencyFixes),
EditorState.languageData.of(() => [{ openideaCompletion: completion }]),
];
return EditorState.languageData.of(() => [{ openideaCompletion: completion }]);
}
export function activate(): () => void {
return registerEditorContribution({
id: "dependency-versions",
extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []),
extensions: (doc) => {
if (doc.virtual || doc.readOnly) return [];
if (/(?:^|\/)Cargo\.toml$/.test(doc.path)) return extension(doc);
if (/(?:^|\/)go\.mod$/.test(doc.path)) return versionCompletion(doc);
return [];
},
});
}
+181 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import type { View, ViewEvent } from "@/bindings";
@@ -419,6 +419,7 @@ describe("PluginView", () => {
render(<PluginView pluginId="csv" view="table" doc={7} />);
await user.dblClick(await screen.findByText("London"));
const input = screen.getByLabelText("Cell value");
expect(events.some((e) => e.kind === "tableActivate")).toBe(false);
await user.clear(input);
await user.type(input, "Paris{Enter}");
await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" }));
@@ -533,3 +534,182 @@ describe("PluginView", () => {
expect(await screen.findByText("boom")).toBeInTheDocument();
});
});
describe("API 1.3 read-only values and table rows", () => {
it("shows selectable labels and readonly text without sending field changes", async () => {
const events: ViewEvent[] = [];
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "form",
value: [
{ key: "title", label: "Title", value: { kind: "label", value: "Merge request" } },
{
key: "author",
label: "Author",
value: { kind: "readonlyText", value: { text: "Ada", multiline: false } },
},
{
key: "body",
label: "Description",
value: { kind: "readonlyText", value: { text: "one\ntwo", multiline: true } },
},
],
},
}),
events,
),
);
const user = userEvent.setup();
render(<PluginView pluginId="jobs" view="form" />);
expect(await screen.findByLabelText("Title")).toHaveTextContent("Merge request");
for (const label of ["Author", "Description"]) {
const input = screen.getByLabelText(label);
expect(input).toHaveAttribute("readonly");
await user.type(input, "changed");
await user.tab();
}
expect(screen.getByLabelText("Author")).toHaveValue("Ada");
expect(screen.getByLabelText("Description")).toHaveValue("one\ntwo");
expect(events).toEqual([]);
});
it("keeps editable table row actions beside contributed commands", async () => {
const events: ViewEvent[] = [];
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Value"],
rows: [["Editable"]],
totalRows: 1,
firstRow: 0,
editable: true,
sortable: false,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="table" itemMenu={() => [{ label: "Inspect editable", run: () => {} }]} />
</>,
);
const cell = await screen.findByText("Editable");
await user.pointer({ target: cell, keys: "[MouseRight]" });
expect(screen.getByText("Inspect editable")).toBeInTheDocument();
expect(screen.getByText("Insert Row Above")).toBeInTheDocument();
await user.click(screen.getByText("Delete Row"));
expect(events).toContainEqual({ kind: "deleteRows", rows: [0] });
hideContextMenu();
});
it("uses absolute row indices after paging and supports keyboard context menus", async () => {
const events: ViewEvent[] = [];
const itemMenu = vi.fn(() => [{ label: "Inspect paged row", run: () => {} }]);
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Route"],
rows: [["/paged"]],
totalRows: 100,
firstRow: 50,
editable: false,
sortable: true,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="routes" itemMenu={itemMenu} />
</>,
);
const grid = await screen.findByRole("grid");
fireEvent.scroll(grid, { target: { scrollTop: 50 * 24 } });
const cell = await screen.findByText("/paged");
await user.click(cell);
expect(events).toContainEqual({ kind: "tableSelect", row: 50 });
(cell.closest('[role="row"]') as HTMLElement).focus();
await user.keyboard("{Enter}{Shift>}{F10}{/Shift}");
expect(events).toContainEqual({ kind: "tableActivate", row: 50 });
expect(itemMenu).toHaveBeenCalledWith("50");
expect(screen.getByText("Inspect paged row")).toBeInTheDocument();
hideContextMenu();
});
it.each([false, true])(
"sends absolute row events and contributed context commands (sortable=%s)",
async (sortable) => {
const events: ViewEvent[] = [];
const menuRun = vi.fn();
const itemMenu = vi.fn((key: string) => [{ label: `Inspect ${key}`, run: menuRun }]);
setPluginsBackend(
backendWith(
() => ({
toolbar: [],
status: null,
body: {
kind: "table",
value: {
columns: ["Route"],
rows: [["/users"], ["/posts"]],
totalRows: 2,
firstRow: 0,
editable: false,
sortable,
sort: null,
},
},
}),
events,
),
);
const user = userEvent.setup();
render(
<>
<ContextMenuHost />
<PluginView pluginId="jobs" view="routes" itemMenu={itemMenu} />
</>,
);
const cell = await screen.findByText("/posts");
await user.click(cell);
expect(events).toContainEqual({ kind: "tableSelect", row: 1 });
expect(cell.closest('[role="row"]')).toHaveAttribute("aria-selected", "true");
await user.dblClick(cell);
expect(events).toContainEqual({ kind: "tableActivate", row: 1 });
const row = cell.closest('[role="row"]') as HTMLElement;
row.focus();
await user.keyboard("{Enter} ");
expect(events.filter((e) => e.kind === "tableActivate")).toHaveLength(2);
await user.pointer({ target: cell, keys: "[MouseRight]" });
expect(itemMenu).toHaveBeenCalledWith("1");
await user.click(screen.getByText("Inspect 1"));
expect(menuRun).toHaveBeenCalledOnce();
expect(screen.queryByText("Insert Row Above")).not.toBeInTheDocument();
hideContextMenu();
},
);
});
@@ -226,3 +226,22 @@
width: var(--h-floating-toolbar-button);
height: var(--h-floating-toolbar-button);
}
.readonlyValue {
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
/* Selectable, but framed like a disabled field so it doesn't read as editable. */
.readonlyField:read-only {
border: 0;
border-radius: var(--radius-control, 4px);
background: transparent;
box-shadow: inset 0 0 0 1px var(--border);
}
.tableRow:focus-visible {
outline: 1px solid var(--focus-ring);
outline-offset: -1px;
}
+52 -8
View File
@@ -16,6 +16,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import clsx from "clsx";
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import {
@@ -46,7 +47,7 @@ export interface PluginViewProps {
revision?: number;
/** Called after an event was handled (custom editors re-render on the document instead). */
label?: string;
/** Context menu entries of a tree or list item, by item key. */
/** Context menu entries by item key (tables use the absolute row index). */
itemMenu?: (key: string) => MenuItem[];
}
@@ -203,7 +204,7 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps
</div>
);
case "table":
return <TableBody table={body.value} send={send} onViewport={onViewport} />;
return <TableBody table={body.value} send={send} onViewport={onViewport} itemMenu={itemMenu} />;
case "html":
return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />;
}
@@ -283,6 +284,17 @@ function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) =>
{field.label}
</label>
)}
{v.kind === "label" && (
<output id={id} className={styles.readonlyValue}>
{v.value}
</output>
)}
{v.kind === "readonlyText" &&
(v.value.multiline ? (
<textarea id={id} className={clsx(styles.textarea, styles.readonlyField)} value={v.value.text} readOnly />
) : (
<Input id={id} className={styles.readonlyField} value={v.value.text} readOnly />
))}
{v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />}
{v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />}
{v.kind === "checkbox" && (
@@ -347,10 +359,12 @@ function TableBody({
table,
send,
onViewport,
itemMenu,
}: {
table: ViewTable;
send: (e: ViewEvent) => void;
onViewport: (v: Viewport) => void;
itemMenu?: (key: string) => MenuItem[];
}) {
const scroller = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
@@ -375,10 +389,13 @@ function TableBody({
if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows));
}, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]);
const rowMenu = (row: number, e: React.MouseEvent) => {
const rowMenu = (row: number, e: ReactMouseEvent | ReactKeyboardEvent) => {
e.preventDefault();
const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row];
if (!selected.has(row)) setSelected(new Set([row]));
if (!selected.has(row)) {
setSelected(new Set([row]));
send({ kind: "tableSelect", row });
}
const items: MenuItem[] = [
{ label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) },
{ label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) },
@@ -390,7 +407,14 @@ function TableBody({
run: () => send({ kind: "deleteRows", rows }),
},
];
if (table.editable) showContextMenu(e, items);
const contributed = itemMenu?.(String(row)) ?? [];
const menu = table.editable
? [...contributed, ...(contributed.length ? [{ kind: "separator" } as const] : []), ...items]
: contributed;
if (menu.length) {
const rect = e.currentTarget.getBoundingClientRect();
showContextMenu("clientX" in e ? e : { x: rect.left, y: rect.bottom }, menu);
}
};
// A sortable table (`table` through the plugin API's `other` body case):
@@ -411,15 +435,35 @@ function TableBody({
aria-selected={selected.has(r)}
className={styles.tableRow}
style={{ top: (r + 1) * ROW_HEIGHT }}
onClick={(e) =>
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("input, textarea")) return;
setSelected((s) => {
if (!(e.metaKey || e.ctrlKey)) return new Set([r]);
const next = new Set(s);
if (next.has(r)) next.delete(r);
else next.add(r);
return next;
})
}
});
send({ kind: "tableSelect", row: r });
}}
onDoubleClick={(e) => {
if (!table.editable || (e.target as HTMLElement).closest('[role="rowheader"]'))
send({ kind: "tableActivate", row: r });
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter") {
e.preventDefault();
send({ kind: "tableActivate", row: r });
} else if (e.key === " ") {
e.preventDefault();
setSelected(new Set([r]));
send({ kind: "tableSelect", row: r });
} else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
rowMenu(r, e);
}
}}
onContextMenu={(e) => rowMenu(r, e)}
>
<div className={styles.rowNumber} role="rowheader">
+1
View File
@@ -198,6 +198,7 @@
"window.caption.minimize": "Minimize",
"window.caption.maximize": "Maximize",
"window.caption.restore": "Restore Down",
"window.caption.exitFullScreen": "Exit Full Screen",
"window.caption.close": "Close",
"menu.help.keymapReference": "Keymap Reference",
"menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}",
+12 -9
View File
@@ -1,13 +1,7 @@
{
"Install 'openidea' Command": "Install 'openidea' Command",
"Uninstall 'openidea' Command": "Uninstall 'openidea' Command",
"The openidea command is installed": "The openidea command is installed",
"The openidea command is uninstalled": "The openidea command is uninstalled",
"The openidea command is not installed": "The openidea command is not installed",
"Cannot install the openidea command": "Cannot install the openidea command",
"Cannot uninstall the openidea command": "Cannot uninstall the openidea command",
"Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.",
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.",
"Run `{command}` in a terminal.": "Run `{command}` in a terminal.",
"Run `{command}` in a terminal and sign in.": "Run `{command}` in a terminal and sign in.",
"{name} needs you to sign in. {hint}": "{name} needs you to sign in. {hint}",
"OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings",
"Import JetBrains Settings": "Import JetBrains Settings",
"These settings will be imported:": "These settings will be imported:",
@@ -99,6 +93,15 @@
"WAR Archive": "WAR Archive",
"WAR (exploded)": "WAR (exploded)",
"No project is currently open.": "No project is currently open.",
"Install 'openidea' Command": "Install 'openidea' Command",
"Uninstall 'openidea' Command": "Uninstall 'openidea' Command",
"The openidea command is installed": "The openidea command is installed",
"The openidea command is uninstalled": "The openidea command is uninstalled",
"The openidea command is not installed": "The openidea command is not installed",
"Cannot install the openidea command": "Cannot install the openidea command",
"Cannot uninstall the openidea command": "Cannot uninstall the openidea command",
"Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.",
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.",
"Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "Default SDK used by modules, builds, and run configurations unless overridden at the module level.",
"Project SDK": "Project SDK",
"Default Java language level for source code assistance and compilation across project modules.": "Default Java language level for source code assistance and compilation across project modules.",
+12 -9
View File
@@ -1,13 +1,7 @@
{
"Install 'openidea' Command": "安装 'openidea' 命令",
"Uninstall 'openidea' Command": "卸载 'openidea' 命令",
"The openidea command is installed": "openidea 命令已安装",
"The openidea command is uninstalled": "openidea 命令已卸载",
"The openidea command is not installed": "openidea 命令未安装",
"Cannot install the openidea command": "无法安装 openidea 命令",
"Cannot uninstall the openidea command": "无法卸载 openidea 命令",
"Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。",
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。",
"Run `{command}` in a terminal.": "在终端中运行 `{command}`。",
"Run `{command}` in a terminal and sign in.": "在终端中运行 `{command}` 并登录。",
"{name} needs you to sign in. {hint}": "{name} 需要你登录。{hint}",
"Cannot copy reference": "无法复制引用",
"Group actions": "分组操作",
"New Project Group": "新建项目组",
@@ -1686,6 +1680,15 @@
"WAR Archive": "WAR 归档",
"WAR (exploded)": "WAR(展开)",
"No project is currently open.": "当前未打开任何项目。",
"Install 'openidea' Command": "安装 'openidea' 命令",
"Uninstall 'openidea' Command": "卸载 'openidea' 命令",
"The openidea command is installed": "openidea 命令已安装",
"The openidea command is uninstalled": "openidea 命令已卸载",
"The openidea command is not installed": "openidea 命令未安装",
"Cannot install the openidea command": "无法安装 openidea 命令",
"Cannot uninstall the openidea command": "无法卸载 openidea 命令",
"Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。",
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。",
"Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "模块、构建和运行配置默认使用的 SDK(除非在模块级别覆盖)。",
"Project SDK": "项目 SDK",
"Default Java language level for source code assistance and compilation across project modules.": "项目各模块用于源码辅助和编译的默认 Java 语言级别。",
+1
View File
@@ -198,6 +198,7 @@
"window.caption.minimize": "最小化",
"window.caption.maximize": "最大化",
"window.caption.restore": "向下还原",
"window.caption.exitFullScreen": "退出全屏",
"window.caption.close": "关闭",
"menu.help.keymapReference": "快捷键参考",
"menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}",
+10
View File
@@ -436,6 +436,16 @@ html {
--fg-caption-close-hover: #ffffff;
}
/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */
:root {
--mac-window-close: #ff5f57;
--mac-window-minimize: #febc2e;
--mac-window-zoom: #28c840;
--mac-window-glyph: rgb(0 0 0 / 55%);
--mac-window-edge: rgb(0 0 0 / 12%);
--mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent);
}
/*
* Islands Light. Every colour token of :root is redefined here. Values are
* PyCharm's own; its secondary greys, comment / metadata colours, line numbers
+3
View File
@@ -248,6 +248,9 @@ const ICONS = {
"window-maximize": "openidea/general/windowMaximize",
"window-restore": "openidea/general/windowRestore",
"window-close": "openidea/general/windowClose",
"mac-window-close": "openidea/general/macWindowClose",
"mac-window-minimize": "openidea/general/macWindowMinimize",
"mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen",
hide: "general/hide",
pin: "general/pin",
eye: "general/show",
@@ -0,0 +1,4 @@
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

@@ -0,0 +1,5 @@
<!-- OpenIDEA: macOS window button glyph (exit full screen), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.75 2.5V7.25H13.5L8.75 2.5Z" fill="#6C707E"/>
<path d="M7.25 13.5V8.75H2.5L7.25 13.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,4 @@
<!-- OpenIDEA: macOS window button glyph (minimize), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 8H12.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

+87
View File
@@ -0,0 +1,87 @@
/**
* macOS window buttons in the title bar's slot while the window fills the screen. Native full
* screen moves the system's buttons into the bar revealed with the menu bar, and a project tab
* shown in its group's full-screen Space has none, so the slot kept for them would be empty.
* Close closes this project's tab, the green button leaves full screen, minimize is disabled.
* Like the system's, they take no keyboard focus.
*/
import { useEffect, useState } from "react";
import clsx from "clsx";
import { t } from "@/platform/i18n";
import { commands, onIdeEvent } from "@/platform/ipc";
import { Icon } from "@/ui";
import styles from "./TitleBar.module.css";
type AppWindow = import("@tauri-apps/api/window").Window;
function useFullscreenWindow(): { fullscreen: boolean; active: boolean } {
const [fullscreen, setFullscreen] = useState(false);
const [active, setActive] = useState(() => document.hasFocus());
useEffect(() => {
let disposed = false;
const offs: (() => void)[] = [];
const sync = () => void commands.windowIsFullscreen().then((on) => !disposed && setFullscreen(on));
sync();
offs.push(onIdeEvent("windowFullscreenChanged", sync));
void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => {
const w: AppWindow = getCurrentWindow();
const listeners = await Promise.all([
w.onResized(sync),
w.onFocusChanged(({ payload }) => {
setActive(payload);
sync();
}),
]);
if (disposed) listeners.forEach((off) => off());
else offs.push(...listeners);
});
return () => {
disposed = true;
offs.forEach((off) => off());
};
}, []);
return { fullscreen, active };
}
function closeWindow() {
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close());
}
export function MacWindowButtons() {
const { fullscreen, active } = useFullscreenWindow();
if (!fullscreen) return null;
return (
<div
className={clsx(styles.macButtons, !active && styles.macButtonsInactive)}
onMouseDown={(e) => e.preventDefault()}
>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macClose)}
aria-label={t("window.caption.close")}
onClick={closeWindow}
>
<Icon name="mac-window-close" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macMinimize)}
aria-label={t("window.caption.minimize")}
disabled
>
<Icon name="mac-window-minimize" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macZoom)}
aria-label={t("window.caption.exitFullScreen")}
onClick={() => void commands.windowToggleFullscreen()}
>
<Icon name="mac-window-exit-full-screen" size={8} />
</button>
</div>
);
}
+49
View File
@@ -195,3 +195,52 @@
background: var(--caption-close-pressed);
color: var(--fg-caption-close-hover);
}
/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */
.macButtons {
position: fixed;
top: 0;
left: 0;
height: var(--h-titlebar);
display: flex;
align-items: center;
gap: 8px;
padding-left: 12px;
zoom: calc(1 / var(--titlebar-zoom, 1));
z-index: 1;
}
.macButton {
width: 12px;
height: 12px;
padding: 0;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--mac-window-glyph);
box-shadow: inset 0 0 0 0.5px var(--mac-window-edge);
}
.macButton > * {
opacity: 0;
}
.macButtons:hover .macButton:not(:disabled) > * {
opacity: 1;
}
.macClose {
background: var(--mac-window-close);
}
.macMinimize {
background: var(--mac-window-minimize);
}
.macZoom {
background: var(--mac-window-zoom);
}
.macButton:active:not(:disabled) {
filter: brightness(0.8);
}
/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */
.macButton:disabled,
.macButtonsInactive:not(:hover) .macButton {
background: var(--mac-window-inactive);
}
+3
View File
@@ -21,6 +21,7 @@ import {
widgetTitle,
} from "./mainToolbar";
import { WindowControls } from "./WindowControls";
import { MacWindowButtons } from "./MacWindowButtons";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch";
@@ -301,6 +302,7 @@ export function TitleBar({
if (bare)
return (
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
{overlay && <MacWindowButtons />}
{frameless && (
<>
<MainMenu />
@@ -323,6 +325,7 @@ export function TitleBar({
showContextMenu(e, menu);
}}
>
{overlay && <MacWindowButtons />}
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
{title && (
<span className={styles.windowTitle} {...windowDragRegion}>
+4
View File
@@ -36,6 +36,10 @@ interface events {
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: string) -> result<_, error>;
/// Replaces this kind's subscription with project-relative globs (OR).
/// Empty globs pause delivery; `subscribe` restores unfiltered delivery.
/// Kinds: file-opened, file-saved. Requires `events`.
subscribe-paths: func(kind: string, globs: list<string>) -> result<_, error>;
unsubscribe: func(kind: string);
}
+4
View File
@@ -8,6 +8,10 @@ interface workspace {
/// `path` is an absolute directory path on the UI machine.
open-project: func(path: string, new-window: bool) -> result<_, error>;
/// The open project containing an absolute document path, or `none`.
/// Reading the returned project still requires `project.read`.
project-of: func(path: string) -> option<project>;
/// What a path inside a project is.
enum file-kind {
file,