mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
commit
97abcc4dc8
91 files changed
+2283
-123
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316
|
||||
abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf
|
||||
7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a
|
||||
d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99
|
||||
+1
-1
@@ -1 +1 @@
|
||||
c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696
|
||||
2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a
|
||||
+1
-1
@@ -1 +1 @@
|
||||
65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892
|
||||
4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb
|
||||
+1
-1
@@ -1 +1 @@
|
||||
d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852
|
||||
d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748
|
||||
@@ -1 +1 @@
|
||||
d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809
|
||||
582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a
|
||||
3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268
|
||||
2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949
|
||||
dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47
|
||||
+1
-1
@@ -1 +1 @@
|
||||
11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb
|
||||
8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340
|
||||
@@ -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`).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 语言级别。",
|
||||
|
||||
@@ -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 {在文件管理器中显示日志}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user