mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a4ef162bb | ||
|
|
278936dd7d | ||
|
|
49a113c875 | ||
|
|
2d6394f203 | ||
|
|
b57fba8b43 | ||
|
|
6394f5904a | ||
|
|
7f62011a08 | ||
|
|
5ea9a2a3a9 | ||
|
|
0bfd4fa3cc | ||
|
|
25424465c0 | ||
|
|
cbe4355b8d | ||
|
|
47d547e206 | ||
|
|
f42d10d86c | ||
|
|
86f390f2a9 | ||
|
|
78784837d1 | ||
|
|
4d968a2aca | ||
|
|
f6e942dd00 | ||
|
|
75cb45ed94 | ||
|
|
f07627ada6 | ||
|
|
48c5ba51b6 | ||
|
|
558ee395dc | ||
|
|
63304ec9fe | ||
|
|
65f3660bd9 | ||
|
|
105e730555 | ||
|
|
3a0a95ac90 | ||
|
|
1439adc00c | ||
|
|
0b08cde4ba | ||
|
|
cde358888c | ||
|
|
48393b2c8c | ||
|
|
587819d9ca | ||
|
|
86ed4d6c59 | ||
|
|
11911a12c6 |
No files matched your search
Generated
+52
-37
@@ -4106,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4143,7 +4143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4154,7 +4154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4168,9 +4168,23 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4179,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4196,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4210,7 +4224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4231,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4258,7 +4272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4271,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4291,7 +4305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4303,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4316,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4378,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4396,7 +4410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4418,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4435,7 +4449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4491,7 +4505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4517,7 +4531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4540,7 +4554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4561,7 +4575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4574,7 +4588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4592,7 +4606,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4616,7 +4630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4635,7 +4649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4655,7 +4669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4668,7 +4682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4689,7 +4703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4704,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4717,7 +4731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4730,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4751,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4772,7 +4786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4796,7 +4810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4812,7 +4826,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6358,7 +6372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6370,6 +6384,7 @@ dependencies = [
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-cli",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -27,6 +27,29 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
pub(crate) const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
|
||||
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
|
||||
if code == AUTH_REQUIRED {
|
||||
return true;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| {
|
||||
*w == "auth"
|
||||
|| w.starts_with("authenticat")
|
||||
|| w.starts_with("authoriz")
|
||||
|| w.starts_with("unauthenticat")
|
||||
|| w.starts_with("unauthoriz")
|
||||
|| w.starts_with("oauth")
|
||||
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|
||||
|| words
|
||||
.windows(2)
|
||||
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
|
||||
}
|
||||
|
||||
/// The capabilities this client offers, in v1 form.
|
||||
fn client_capabilities() -> Value {
|
||||
json!({
|
||||
|
||||
@@ -16,6 +16,42 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
|
||||
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
|
||||
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
|
||||
|
||||
#[test]
|
||||
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
|
||||
for (code, message, expected) in [
|
||||
(AUTH_REQUIRED, "This account is not eligible.", true),
|
||||
(codes::INTERNAL_ERROR, "Auth required", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
|
||||
(codes::INTERNAL_ERROR, "Not authenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Authorization failed", true),
|
||||
(codes::INTERNAL_ERROR, "Authorize your account", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthentication error", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorization error", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in", true),
|
||||
(codes::INTERNAL_ERROR, "You are logged out", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in", true),
|
||||
(codes::INTERNAL_ERROR, "Please log in", true),
|
||||
(codes::INTERNAL_ERROR, "Login required", true),
|
||||
(codes::INTERNAL_ERROR, "author", false),
|
||||
(codes::INTERNAL_ERROR, "authors", false),
|
||||
(codes::INTERNAL_ERROR, "authority", false),
|
||||
(codes::INTERNAL_ERROR, "authored", false),
|
||||
(codes::INTERNAL_ERROR, "reauthenticate", false),
|
||||
(codes::INTERNAL_ERROR, "preauthorized", false),
|
||||
(codes::INTERNAL_ERROR, "signal input failed", false),
|
||||
(codes::INTERNAL_ERROR, "catalog index failed", false),
|
||||
(codes::INTERNAL_ERROR, "login succeeded", false),
|
||||
] {
|
||||
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages of a fixture going `dir` (`in` or `out`).
|
||||
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
|
||||
fixture
|
||||
|
||||
@@ -35,20 +35,32 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
/// Whether the current settings approve tool requests without asking: a bypass mode,
|
||||
/// an allow-all permissions option, or a bypass approval policy.
|
||||
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| {
|
||||
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
})?;
|
||||
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
fn is_bypass_mode(mode: &str) -> bool {
|
||||
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
|
||||
let lower = mode.to_lowercase();
|
||||
let m: String =
|
||||
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -267,6 +279,52 @@ mod tests {
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bypass_settings_approve_without_asking() {
|
||||
let full_access = options_of(&json!({
|
||||
"modes": {"currentModeId": "full-access", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(bypass_approvals(&full_access));
|
||||
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
|
||||
|
||||
let asking = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(!bypass_approvals(&asking));
|
||||
assert_eq!(
|
||||
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
|
||||
Some("full-access"),
|
||||
"the offered bypass mode stays available"
|
||||
);
|
||||
|
||||
let permissions = |current: &str| {
|
||||
options_of(&json!({"configOptions": [{
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
|
||||
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
|
||||
}]}))
|
||||
};
|
||||
assert!(bypass_approvals(&permissions("on")));
|
||||
assert!(bypass_approvals(&permissions("TRUE")));
|
||||
assert!(!bypass_approvals(&permissions("off")));
|
||||
|
||||
let approval = options_of(&json!({"configOptions": [{
|
||||
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
|
||||
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
|
||||
}]}));
|
||||
assert!(bypass_approvals(&approval));
|
||||
|
||||
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
|
||||
let url_mode = options_of(&json!({
|
||||
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
|
||||
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
|
||||
}));
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
|
||||
+23
-14
@@ -1394,13 +1394,22 @@ impl Task {
|
||||
};
|
||||
return Failure { message, limit: Some(resets) };
|
||||
}
|
||||
match e {
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
|
||||
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
|
||||
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
|
||||
}
|
||||
|
||||
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
|
||||
let RpcError::Server { code, message, .. } = e else { return None };
|
||||
if !acp::auth_required(*code, message) {
|
||||
return None;
|
||||
}
|
||||
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
|
||||
let Some(guidance) = guidance else { return Some(message.clone()) };
|
||||
Some(if message.trim().is_empty() || guidance.contains(message) {
|
||||
guidance.to_owned()
|
||||
} else {
|
||||
format!("{guidance}\n\n{message}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
@@ -1419,7 +1428,6 @@ impl Task {
|
||||
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);
|
||||
@@ -1439,6 +1447,7 @@ impl Task {
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
|
||||
|
||||
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
|
||||
let sign_in_message = spec.sign_in_message(&init.auth_methods);
|
||||
let can_load = init.load_session();
|
||||
let (capabilities, embedded) = capabilities_of(&init);
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
@@ -1463,12 +1472,7 @@ impl Task {
|
||||
let v: Value = peer
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
sign_in_message.clone()
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
(id, v)
|
||||
}
|
||||
@@ -1581,6 +1585,7 @@ impl Task {
|
||||
.collect()
|
||||
};
|
||||
if due.is_empty() {
|
||||
self.approve_pending();
|
||||
return;
|
||||
}
|
||||
let task = self.clone();
|
||||
@@ -1610,6 +1615,7 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
|
||||
}
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
});
|
||||
@@ -1726,6 +1732,9 @@ impl Task {
|
||||
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
|
||||
self.apply_late_choices();
|
||||
}
|
||||
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
|
||||
self.approve_pending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.replaying.load(Ordering::Acquire) {
|
||||
@@ -1857,7 +1866,7 @@ impl Task {
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
|
||||
@@ -8,6 +8,7 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
use super::Shim;
|
||||
use crate::acp::AUTH_REQUIRED;
|
||||
use crate::provider::ProviderSpec;
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::Mutex;
|
||||
@@ -27,8 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -204,7 +203,7 @@ fn from_codex(e: RpcError) -> ResponseError {
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
|
||||
@@ -623,6 +623,23 @@ async fn auth_failure_names_auth() {
|
||||
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
|
||||
for (code, message, expected) in [
|
||||
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
|
||||
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
|
||||
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
|
||||
] {
|
||||
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
|
||||
assert_eq!(response.code, expected, "{message}");
|
||||
assert_eq!(response.message, message);
|
||||
assert_eq!(response.data, Some(json!({"detail": 1})));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -92,9 +93,26 @@ impl ProviderSpec {
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self) -> String {
|
||||
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
|
||||
let name = &self.name;
|
||||
let hint = self.login_hint(Path::new(&self.command));
|
||||
let program = Path::new(&self.command);
|
||||
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
|
||||
.then(|| {
|
||||
auth_methods.iter().find_map(|method| {
|
||||
if method.get("type").and_then(Value::as_str) != Some("terminal") {
|
||||
return None;
|
||||
}
|
||||
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let hint = match terminal_args {
|
||||
Some(args) => {
|
||||
let command = render_login_command(program, args, cfg!(windows));
|
||||
format!("Run `{command}` in a terminal.")
|
||||
}
|
||||
None => self.login_hint(program),
|
||||
};
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
@@ -124,14 +142,8 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
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}");
|
||||
let command = render_login_command(program, args.split_whitespace(), windows);
|
||||
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."),
|
||||
@@ -139,6 +151,22 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
|
||||
let quote = |word: &str| {
|
||||
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
|
||||
};
|
||||
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
|
||||
command.push_str(arg);
|
||||
} else {
|
||||
command.push_str("e(arg));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -370,6 +398,7 @@ fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
@@ -429,12 +458,43 @@ mod tests {
|
||||
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()));
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_login_commands_quote_the_binary_and_each_argument() {
|
||||
for (windows, expected) in [
|
||||
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
|
||||
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
|
||||
] {
|
||||
assert_eq!(
|
||||
render_login_command(
|
||||
Path::new("/agent's tools/$cli"),
|
||||
["auth", "login", "--account", "user name", "a'b", "$token", ""],
|
||||
windows
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.id = "custom".into();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
for method in [
|
||||
json!({"type": "terminal"}),
|
||||
json!({"type": "terminal", "args": ["login", null]}),
|
||||
json!({"type": "agent", "args": ["login"]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
spec.sign_in_message(&[method]),
|
||||
"Cursor needs you to sign in. Sign in with the agent's own command line first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
|
||||
@@ -35,6 +35,10 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
request_error: Option<(i64, String)>,
|
||||
auth_methods: Vec<Value>,
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
hold_late_mode: bool,
|
||||
empty_config: AtomicBool,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
@@ -126,7 +130,17 @@ impl Handler for FakeAgent {
|
||||
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()));
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
.unwrap_or((codes::INTERNAL_ERROR, "Authentication required".into())));
|
||||
}
|
||||
if self.hold_late_mode
|
||||
&& method == "session/set_config_option"
|
||||
&& params.get("value").and_then(Value::as_str) == Some("full-access")
|
||||
{
|
||||
// Let the prompt's permission request reach the task before the mode changes.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
@@ -134,8 +148,10 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
|
||||
Ok(
|
||||
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
|
||||
)
|
||||
}
|
||||
"session/new" => {
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
@@ -200,6 +216,29 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "ok");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "late full access" {
|
||||
// The mode option appears only now; the user's choice is applied late.
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "config_option_update", "configOptions": [
|
||||
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
|
||||
]}),
|
||||
);
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("late chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -360,7 +399,110 @@ async fn authentication_failures_name_the_session_executable_at_start_and_during
|
||||
.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}");
|
||||
assert!(error.ends_with(" login` in a terminal.\n\nAuthentication required"), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sign_in_errors_preserve_details_and_leave_unrelated_errors_unchanged() {
|
||||
for auth_on_new in [true, false] {
|
||||
for (code, message, sign_in) in [
|
||||
(-32000, "This Google account is not eligible for this agent.", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: token expired for account alice.", true),
|
||||
(codes::INTERNAL_ERROR, "UNAUTHORIZED: account refused", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in to this installation.", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in with your work account.", true),
|
||||
(codes::INTERNAL_ERROR, "Missing author field", false),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority in configuration", false),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent =
|
||||
Arc::new(FakeAgent { auth_on_new, request_error: Some((code, message.into())), ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
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;
|
||||
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,
|
||||
})
|
||||
.unwrap();
|
||||
if sign_in {
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.ends_with(&format!("\n\n{message}")), "{error}");
|
||||
} else {
|
||||
assert_eq!(error, message);
|
||||
}
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
|
||||
for terminal in [true, false] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent {
|
||||
auth_on_new: true,
|
||||
request_error: Some((-32000, "This account is not eligible.".into())),
|
||||
auth_methods: vec![json!({"type": if terminal { "terminal" } else { "agent" },
|
||||
"id": "cli", "name": "CLI", "args": ["auth", "login"]})],
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let program = tmp.path().join("Registry Tools").join("antigravity.cmd");
|
||||
let mut spec = provider::builtin_spec("cursor").unwrap();
|
||||
spec.id = "antigravity".into();
|
||||
spec.name = "Google Antigravity".into();
|
||||
spec.command = program.to_string_lossy().into_owned();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
mgr.shared.providers.write().push(spec);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "antigravity".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(error.starts_with("Google Antigravity needs you to sign in. "), "{error}");
|
||||
if terminal {
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.contains(" auth login` in a terminal."), "{error}");
|
||||
} else {
|
||||
assert!(error.contains("Sign in with the agent's own command line first."), "{error}");
|
||||
}
|
||||
assert!(error.ends_with("\n\nThis account is not eligible."), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -1438,12 +1580,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
assert!(config::bypass_approvals(&t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
@@ -1534,6 +1676,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_full_access_mode_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
|
||||
// Choosing Full Access while the request is already waiting also approves it.
|
||||
let waiting = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&waiting.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_late_full_access_option_approves_a_pending_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("late full access"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let task = mgr.task(&task.id).unwrap();
|
||||
let (status, busy) = {
|
||||
let s = task.state.lock();
|
||||
(s.task.status, s.busy)
|
||||
};
|
||||
if status == AgentStatus::Ready && !busy {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = mgr.cancel(&task.id, None);
|
||||
mgr.shutdown();
|
||||
panic!("late Full Access did not approve the pending request");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_allow_all_permissions_option_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
|
||||
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn archived_tasks_can_be_restored_and_continue_their_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_name_with_a_colon_wins_over_a_position() {
|
||||
let files = ["/work/b:2"];
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_cli_install"
|
||||
| "app_cli_uninstall"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
|
||||
@@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
for command in [
|
||||
"app_info",
|
||||
"app_build_info",
|
||||
"app_cli_install",
|
||||
"app_cli_uninstall",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
|
||||
@@ -692,6 +692,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_lsp_serves_php_files_only_below_artisan() {
|
||||
let reg = reg();
|
||||
let cfg = reg.get(&LanguageId::new("php")).unwrap();
|
||||
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
|
||||
// After the fallback: hover, rename and symbols stay with Intelephense.
|
||||
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
|
||||
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
|
||||
assert_eq!(lsp.command.as_str(), "laravel-lsp");
|
||||
assert!(lsp.require_root_marker && !lsp.fallback);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in ["shop", "lib"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("shop/artisan"), "").unwrap();
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &lsp.root_markers);
|
||||
assert_eq!(served("shop/app/Models/Order.php"), Some(root.join("shop")));
|
||||
assert_eq!(served("lib/src/Parser.php"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_serves_python_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -395,6 +395,10 @@ fn canonical_path(path: &Path) -> IdeResult<PathBuf> {
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in std::path::absolute(path)?.components() {
|
||||
match component {
|
||||
std::path::Component::Prefix(_) => {
|
||||
resolved.push(component);
|
||||
continue;
|
||||
}
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
|
||||
@@ -151,6 +151,25 @@ mod tests {
|
||||
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_lsp_assets() {
|
||||
let names = [
|
||||
"server-v0.0.32-arm64-darwin",
|
||||
"server-v0.0.32-x64-darwin",
|
||||
"server-v0.0.32-arm64-linux",
|
||||
"server-v0.0.32-x64-linux",
|
||||
"server-v0.0.32-x64-win32.exe",
|
||||
];
|
||||
let rules = crate::registry::find("laravel-lsp").unwrap().asset_rules();
|
||||
assert_eq!(select_asset(&names, rules, MAC_ARM), Some(names[0]));
|
||||
assert_eq!(select_asset(&names, rules, MAC_X64), Some(names[1]));
|
||||
assert_eq!(select_asset(&names, rules, LINUX_ARM), Some(names[2]));
|
||||
assert_eq!(select_asset(&names, rules, LINUX_X64), Some(names[3]));
|
||||
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
|
||||
// The composer-installed helper is not a release asset.
|
||||
assert_eq!(select_asset(&["laravel-lsp"], rules, LINUX_X64), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kmp_lsp_assets_include_the_native_jar_indexer() {
|
||||
let names = [
|
||||
|
||||
@@ -396,6 +396,35 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
homepage: "https://intelephense.com",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "laravel-lsp",
|
||||
display: "Laravel LSP",
|
||||
kind: LanguageServer,
|
||||
commands: &["laravel-lsp"],
|
||||
// Plain binaries per platform (`server-v*-<arch>-<os>[.exe]`).
|
||||
method: Method::Release {
|
||||
source: ReleaseSource::GitHub { repo: "laravel/lsp" },
|
||||
assets: &[
|
||||
rule(Mac, Arm64, "server-v*-arm64-darwin"),
|
||||
rule(Mac, X64, "server-v*-x64-darwin"),
|
||||
rule(Linux, Arm64, "server-v*-arm64-linux"),
|
||||
rule(Linux, X64, "server-v*-x64-linux"),
|
||||
rule(Windows, X64, "server-v*-x64-win32.exe"),
|
||||
],
|
||||
locate: &[
|
||||
"laravel-lsp",
|
||||
"server-v*-arm64-darwin",
|
||||
"server-v*-x64-darwin",
|
||||
"server-v*-arm64-linux",
|
||||
"server-v*-x64-linux",
|
||||
"server-v*-x64-win32",
|
||||
],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("laravel-lsp"),
|
||||
homepage: "https://github.com/laravel/lsp",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "gopls",
|
||||
display: "gopls",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Command-line launcher
|
||||
|
||||
Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on
|
||||
macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same
|
||||
action is available in Find Action. Open a new terminal after installation.
|
||||
|
||||
- **macOS:** move OpenIDEA to Applications first. The command links to the launcher
|
||||
inside the app bundle. It uses `~/.local/bin` when that directory is on PATH;
|
||||
otherwise it uses `/usr/local/bin` and may request an administrator password.
|
||||
- **Windows:** installation creates `openidea.cmd` in
|
||||
`%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This
|
||||
works with installed and portable copies, without administrator rights.
|
||||
- **Linux:** installation creates `~/.local/bin/openidea`. If another command
|
||||
takes precedence or this directory is absent from PATH, the notification asks
|
||||
you to put it at the beginning of your shell's PATH. For Bash and Zsh, add
|
||||
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run
|
||||
`fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original
|
||||
AppImage; keep that file in a permanent location.
|
||||
|
||||
```sh
|
||||
openidea .
|
||||
openidea src/main.rs README.md
|
||||
openidea --line 12 --column 5 src/main.rs
|
||||
openidea src/main.rs:12:5
|
||||
openidea diff before.txt after.txt
|
||||
openidea --help
|
||||
openidea --version
|
||||
```
|
||||
|
||||
Folders open as projects. Files open in their project's window, or in LightEdit
|
||||
when no project contains them. Lines and columns start at 1. With no paths, the
|
||||
command focuses OpenIDEA. It starts the app if necessary and returns after handing
|
||||
off the request. Unknown options and missing files exit with code 1.
|
||||
|
||||
Use `--` before a filename beginning with `-`, or before a file named `diff`.
|
||||
The existing GUI executable and OS file associations still work.
|
||||
|
||||
**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows
|
||||
its dedicated user PATH entry. A command belonging to another application is
|
||||
never overwritten or removed. Reinstall the command after moving a portable
|
||||
copy or AppImage to a different location.
|
||||
@@ -112,9 +112,10 @@ for (const theme of ["dark", "light"] as const) {
|
||||
});
|
||||
expect(geometry.scroll).toBe(geometry.client);
|
||||
expect(geometry.contained).toBe(true);
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(4);
|
||||
expect(geometry.replyBackground).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("1px");
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
|
||||
// Replies read as plain text on the panel.
|
||||
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("0px");
|
||||
expect(geometry.userOnRight).toBe(true);
|
||||
expect(geometry.approvalAligned).toBe(true);
|
||||
expect(geometry.authorBeforeWork).toBe(true);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
// A JSX comment is `{/* … */}` among JSX children, while its own text parses as
|
||||
// JavaScript, where the tokens are `/* … */`. Toggling must still take it back out.
|
||||
const SRC = `export function App() {
|
||||
return (
|
||||
<div>
|
||||
<b>x</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
test("⌘/ twice on a JSX line gives the line back", async ({ workbench: page }) => {
|
||||
await page.evaluate(async (src) => {
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const mod = await import(/* @vite-ignore */ backendModule);
|
||||
const backend = mod.docBackend() as { files: Map<string, string> };
|
||||
backend.files.set("/dev/acme-api/App.tsx", src);
|
||||
}, SRC);
|
||||
|
||||
const editor = await openFromTree(page, "App.tsx");
|
||||
const content = editor.locator(".cm-content");
|
||||
await editor.locator(".cm-line", { hasText: "<b>x</b>" }).click();
|
||||
|
||||
await page.keyboard.press("Meta+/");
|
||||
await expect(content).toContainText("{/* <b>x</b> */}");
|
||||
|
||||
await page.keyboard.press("Meta+/");
|
||||
await expect(content).not.toContainText("/*");
|
||||
await expect(editor.locator(".cm-line", { hasText: "<b>x</b>" })).toHaveText("<b>x</b>");
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const [id, selector] of [
|
||||
["general", "[class*='radios'] label"],
|
||||
["notifications", "table tbody td:first-child"],
|
||||
["keymap", "[class*='presetLabel']"],
|
||||
["editor.inspections", "[class*='profileRow'] > span:first-child"],
|
||||
["vcs.issueNavigation", "[class*='pageBody'] button:not([aria-label])"],
|
||||
]) {
|
||||
test(`${id} keeps compact labels inside their controls`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
const localePath = "/src/platform/i18n.ts";
|
||||
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
|
||||
const stagingPath = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ stagingPath)).selectPage(id);
|
||||
}, id);
|
||||
const labels = dialog.locator(selector);
|
||||
await expect(labels.first()).toBeVisible();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await labels.evaluateAll((labels) =>
|
||||
labels.every((label) => {
|
||||
const box = label.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(label);
|
||||
const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
||||
return (
|
||||
lines.every((r) => r.left >= box.left - 1 && r.right <= box.right + 1 && r.height <= 24) &&
|
||||
Math.max(...lines.map((r) => r.bottom)) - Math.min(...lines.map((r) => r.top)) <= 24
|
||||
);
|
||||
}),
|
||||
);
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, selector] of [
|
||||
["general", "[class*='fontRow'] > label"],
|
||||
["build.console", "[class*='inlineRow'] > label"],
|
||||
]) {
|
||||
test(`${id} keeps a readable width for inline text`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
const localePath = "/src/platform/i18n.ts";
|
||||
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage(id);
|
||||
}, id);
|
||||
const text = page.locator(selector).first();
|
||||
await expect(text).toBeVisible();
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
expect((await text.boundingBox())!.width).toBeGreaterThan(100);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const kind of ["password", "sshSecret", "params"] as const) {
|
||||
test(`database ${kind} dialog keeps its fields inside the window`, async ({ workbench: page }) => {
|
||||
await page.evaluate(async (kind) => {
|
||||
const path = "/src/features/database/store.ts";
|
||||
const { openDialog } = await import(/* @vite-ignore */ path);
|
||||
const source = {
|
||||
id: "layout-test",
|
||||
name: "Layout test",
|
||||
driver: "postgres",
|
||||
user: "account_".repeat(30),
|
||||
ssh: { enabled: true, host: "bastion.example.invalid", auth: "password" },
|
||||
};
|
||||
if (kind === "password") openDialog({ kind, source, resolve: () => {} });
|
||||
else if (kind === "sshSecret")
|
||||
openDialog({ kind, source, prompt: "SSH password required for " + "host_".repeat(30), resolve: () => {} });
|
||||
else openDialog({ kind, names: ["parameter_".repeat(25)], resolve: () => {} });
|
||||
}, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
for (const width of [1000, 640, 500]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await dialog.evaluate((dialog) => {
|
||||
const box = dialog.getBoundingClientRect();
|
||||
return [...dialog.querySelectorAll("input, [class*='form_']")].every((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.left >= box.left && r.right <= box.right && el.scrollWidth <= el.clientWidth + 1;
|
||||
});
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const activation of ["double-click", "Enter"] as const) {
|
||||
test(`Keymap ${activation} offers mouse and keyboard shortcuts`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await settings.getByRole("treeitem", { name: "Keymap", exact: true }).click();
|
||||
await settings.getByRole("textbox", { name: "Search actions", exact: true }).fill("Find Action");
|
||||
const row = settings.getByRole("tree", { name: "Actions" }).getByRole("treeitem", { name: /Find Action/ });
|
||||
const activate = async () => {
|
||||
if (activation === "double-click") await row.dblclick();
|
||||
else {
|
||||
await row.click();
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
};
|
||||
await activate();
|
||||
const keyboard = page.getByRole("menuitem", { name: "Add Keyboard Shortcut", exact: true });
|
||||
await expect(keyboard).toBeFocused();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitem", { name: "Add Mouse Shortcut", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
const mouseDialog = page.getByRole("dialog", { name: "Mouse Shortcut: Find Action…", exact: true });
|
||||
await mouseDialog.getByRole("textbox", { name: "Mouse shortcut" }).click({ modifiers: ["Alt"] });
|
||||
await expect(mouseDialog.getByRole("button", { name: "OK", exact: true })).toBeEnabled();
|
||||
await mouseDialog.getByRole("button", { name: "OK", exact: true }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Warning", exact: true })
|
||||
.getByRole("button", { name: "Leave", exact: true })
|
||||
.click();
|
||||
await expect(row).toContainText("Click");
|
||||
await activate();
|
||||
await keyboard.click();
|
||||
const keyboardDialog = page.getByRole("dialog", { name: "Keyboard Shortcut: Find Action…", exact: true });
|
||||
await expect(keyboardDialog.getByRole("textbox", { name: "First stroke" })).toBeFocused();
|
||||
await keyboardDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await row.click({ button: "right" });
|
||||
await expect(keyboard).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Add Mouse Shortcut", exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("postfix template descriptions remain inside their rows", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage("editor.general.postfixCompletion");
|
||||
});
|
||||
const rows = page.getByRole("list", { name: "Postfix templates" }).getByRole("listitem");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
for (const language of ["JavaScript / TypeScript", "Rust", "Python", "Go", "Java", "Kotlin"]) {
|
||||
await page.getByRole("tab", { name: language, exact: true }).click();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await rows.evaluateAll((rows) =>
|
||||
rows.every((row) => {
|
||||
const bounds = row.getBoundingClientRect();
|
||||
const detail = row.lastElementChild!;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(detail);
|
||||
return [...range.getClientRects()].every((r) => r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1);
|
||||
}),
|
||||
);
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const [id, key, name] of [
|
||||
["languages.node", "languages.node.interpreter", "Node interpreter"],
|
||||
["languages.java", "languages.java.jdk", "Project JDK"],
|
||||
["languages.php", "languages.php.interpreter", "PHP interpreter"],
|
||||
]) {
|
||||
test(`${name} keeps its value and browse controls inside their field`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(
|
||||
async ({ id, key }) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { selectPage, stage } = await import(/* @vite-ignore */ path);
|
||||
stage(id, "project", key, "/project/" + "long-path/".repeat(15) + "runtime");
|
||||
selectPage(id);
|
||||
},
|
||||
{ id, key },
|
||||
);
|
||||
const combo = page
|
||||
.getByRole("dialog", { name: "Settings", exact: true })
|
||||
.getByRole("button", { name, exact: true });
|
||||
await expect(combo).toBeVisible();
|
||||
for (const width of [1000, 800, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const geometry = await combo.evaluate((el) => {
|
||||
const field = el.parentElement!;
|
||||
const bounds = field.getBoundingClientRect();
|
||||
return [...field.querySelectorAll("button")].map((button) => {
|
||||
const r = button.getBoundingClientRect();
|
||||
return {
|
||||
left: r.left,
|
||||
right: r.right,
|
||||
width: r.width,
|
||||
fieldLeft: bounds.left,
|
||||
fieldRight: bounds.right,
|
||||
fits: r.width > 0 && r.left >= bounds.left - 1 && r.right <= bounds.right + 1,
|
||||
};
|
||||
});
|
||||
});
|
||||
expect(
|
||||
geometry.every((r) => r.fits),
|
||||
JSON.stringify({ width, geometry }),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Python interpreter controls stay separate and visible in a narrow settings pane", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { selectPage, stage } = await import(/* @vite-ignore */ path);
|
||||
stage(
|
||||
"python.interpreter",
|
||||
"project",
|
||||
"languages.python.interpreter",
|
||||
"/project/" + "long-path/".repeat(15) + "python",
|
||||
);
|
||||
selectPage("python.interpreter");
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
const interpreter = dialog.getByRole("button", { name: "Python interpreter", exact: true });
|
||||
const add = dialog.getByRole("button", { name: "Add Interpreter…", exact: true });
|
||||
await expect(interpreter).toBeVisible();
|
||||
for (const width of [1000, 800, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const combo = await interpreter.boundingBox();
|
||||
const button = await add.boundingBox();
|
||||
const row = await add.evaluate((el) => {
|
||||
const r = el.parentElement!.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right };
|
||||
});
|
||||
expect(combo!.width).toBeGreaterThan(20);
|
||||
expect(combo!.x).toBeGreaterThanOrEqual(row.left - 1);
|
||||
expect(combo!.x + combo!.width).toBeLessThanOrEqual(row.right + 1);
|
||||
expect(button!.x + button!.width).toBeLessThanOrEqual(row.right + 1);
|
||||
const overlap =
|
||||
Math.min(combo!.x + combo!.width, button!.x + button!.width) > Math.max(combo!.x, button!.x) &&
|
||||
Math.min(combo!.y + combo!.height, button!.y + button!.height) > Math.max(combo!.y, button!.y);
|
||||
expect(overlap).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const [id, key, name] of [
|
||||
["languages.node", "languages.node.interpreter", "Node interpreter"],
|
||||
["languages.java", "languages.java.jdk", "Project JDK"],
|
||||
["languages.php", "languages.php.interpreter", "PHP interpreter"],
|
||||
]) {
|
||||
test(`${name} dropdown keeps long custom paths inside the viewport`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
const value = "/project/" + "long-path/".repeat(30) + "runtime";
|
||||
await page.evaluate(
|
||||
async ({ id, key, value }) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { selectPage, stage } = await import(/* @vite-ignore */ path);
|
||||
stage(id, "project", key, value);
|
||||
selectPage(id);
|
||||
},
|
||||
{ id, key, value },
|
||||
);
|
||||
const combo = page.getByRole("button", { name, exact: true });
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
await combo.click();
|
||||
const list = page.getByRole("listbox", { name, exact: true });
|
||||
await expect(list).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
list.evaluate((list) => {
|
||||
const popup = list.closest("[data-popup]")!;
|
||||
const r = popup.getBoundingClientRect();
|
||||
return r.left >= 0 && r.right <= innerWidth && list.scrollWidth <= list.clientWidth + 1;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
const option = list.getByRole("option").filter({ hasText: value });
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option.locator("[title]").first()).toHaveAttribute("title", value);
|
||||
await option.click();
|
||||
await expect(list).toHaveCount(0);
|
||||
await expect(combo).toHaveText(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("dirty settings keep scope labels and navigation inside the page header", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { selectPage, stage } = await import(/* @vite-ignore */ path);
|
||||
stage("languages.node", "project", "languages.node.interpreter", "/custom/node");
|
||||
selectPage("languages.node");
|
||||
});
|
||||
const scope = page.getByRole("radiogroup", { name: "Settings scope" });
|
||||
const reset = page.getByRole("button", { name: "Reset", exact: true });
|
||||
await expect(reset).toBeVisible();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await scope.evaluate((scope) => {
|
||||
const header = scope.parentElement!;
|
||||
const bounds = header.getBoundingClientRect();
|
||||
return [...header.querySelectorAll("button")].every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
return (
|
||||
box.left >= bounds.left &&
|
||||
box.right <= bounds.right &&
|
||||
[...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1)
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
await reset.click();
|
||||
await expect(reset).toHaveCount(0);
|
||||
await expect(scope.getByRole("radio", { name: "This project" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const kind of ["external", "ssh", "target"] as const) {
|
||||
test(`${kind} settings keep long names and details inside their selection row`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async (kind) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { selectPage, stage } = await import(/* @vite-ignore */ path);
|
||||
const remotePath = "/src/platform/remoteSettings.ts";
|
||||
const workflowPath = "/src/platform/workflowSettings.ts";
|
||||
const { DEFAULT_SSH_CONFIG, DEFAULT_RUN_TARGET } = await import(/* @vite-ignore */ remotePath);
|
||||
const { DEFAULT_EXTERNAL_TOOL } = await import(/* @vite-ignore */ workflowPath);
|
||||
const name = "Run integration tests for the current project with the configured environment";
|
||||
const detail = "Shared infrastructure for the staging environment";
|
||||
if (kind === "external") {
|
||||
stage("tools.externalTools", "global", "externalTools", [
|
||||
{ ...DEFAULT_EXTERNAL_TOOL, id: "qa", name, group: detail },
|
||||
]);
|
||||
selectPage("tools.externalTools");
|
||||
} else if (kind === "ssh") {
|
||||
stage("tools.sshConfigurations", "global", "ssh.configs", [
|
||||
{ ...DEFAULT_SSH_CONFIG, id: "qa", name, host: detail, user: "qa" },
|
||||
]);
|
||||
selectPage("tools.sshConfigurations");
|
||||
} else {
|
||||
stage("build.runTargets", "global", "runTargets", [
|
||||
{ ...DEFAULT_RUN_TARGET, id: "qa", name, kind: "docker", image: detail },
|
||||
]);
|
||||
selectPage("build.runTargets");
|
||||
}
|
||||
}, kind);
|
||||
const row = page.getByRole("dialog", { name: "Settings", exact: true }).getByRole("option").first();
|
||||
await expect(row).toBeVisible();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
const fits = await row.evaluate((row) => {
|
||||
const box = row.getBoundingClientRect();
|
||||
return [...row.querySelectorAll(":scope > span")].every((span) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(span);
|
||||
return [...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1);
|
||||
});
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
}
|
||||
await expect(row.locator(":scope > span").first()).toHaveAttribute(
|
||||
"title",
|
||||
"Run integration tests for the current project with the configured environment",
|
||||
);
|
||||
await page.evaluate(async (kind) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
const { stage } = await import(/* @vite-ignore */ path);
|
||||
const [id, key] =
|
||||
kind === "external"
|
||||
? ["tools.externalTools", "externalTools"]
|
||||
: kind === "ssh"
|
||||
? ["tools.sshConfigurations", "ssh.configs"]
|
||||
: ["build.runTargets", "runTargets"];
|
||||
stage(id, "global", key, []);
|
||||
}, kind);
|
||||
const empty = page.getByRole("dialog", { name: "Settings", exact: true }).getByRole("listbox");
|
||||
await expect(row).toHaveCount(0);
|
||||
const hintFits = await empty.evaluate((list) => {
|
||||
const hint = list.firstElementChild!;
|
||||
const box = hint.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(hint);
|
||||
return [...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1);
|
||||
});
|
||||
expect(hintFits).toBe(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const [id, selector] of [
|
||||
["build.deployment", "[class*='layout'] > [class*='note']"],
|
||||
["editor.inspections", "[class*='split'] > [class*='details']"],
|
||||
["languages.servers", "[class*='body'] > [class*='details']"],
|
||||
["plugins", "[class*='body'] > [class*='details']"],
|
||||
["tools.agents", "section[aria-label='Codex executable']"],
|
||||
]) {
|
||||
test(`${id} keeps its details usable beside the list`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
if (id === "tools.agents") {
|
||||
const path = "/src/features/agents/registry.ts";
|
||||
const { tauriCommands } = await import(/* @vite-ignore */ path);
|
||||
tauriCommands.providers = async () => [
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
program: "codex",
|
||||
defaultProgram: "codex",
|
||||
args: [],
|
||||
defaultArgs: [],
|
||||
installed: true,
|
||||
commandOverridden: false,
|
||||
env: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage(id);
|
||||
}, id);
|
||||
const details = dialog.locator(selector).first();
|
||||
await expect(details).toBeAttached();
|
||||
for (const width of [1000, 640, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
expect((await details.boundingBox())!.width).toBeGreaterThan(180);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("every color scheme page keeps its attribute tree visible", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
for (const section of [
|
||||
"general",
|
||||
"language-defaults",
|
||||
"debugger",
|
||||
"python",
|
||||
"markdown",
|
||||
"regexp",
|
||||
"java",
|
||||
"kotlin",
|
||||
"php",
|
||||
]) {
|
||||
await page.evaluate(async (section) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage(`editor.colorScheme.${section}`);
|
||||
}, section);
|
||||
const tree = page.getByRole("tree", { name: "Color scheme attributes" });
|
||||
await expect(tree).toBeAttached();
|
||||
expect((await tree.boundingBox())!.width).toBeGreaterThan(150);
|
||||
}
|
||||
});
|
||||
|
||||
for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
|
||||
test(`${id} keeps its editor reachable beside the template list`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage(id);
|
||||
}, id);
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
const editor = page.locator("[class*='split'] > [class*='editor']").first();
|
||||
await expect(editor).toBeAttached();
|
||||
expect((await editor.boundingBox())!.width).toBeGreaterThan(200);
|
||||
});
|
||||
}
|
||||
|
||||
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
|
||||
});
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
|
||||
await page.getByRole("tab", { name: section, exact: true }).click();
|
||||
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
|
||||
await expect(details).toBeAttached();
|
||||
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
|
||||
}
|
||||
});
|
||||
@@ -26,6 +26,17 @@ args = ["language-server"]
|
||||
root-markers = ["composer.json", ".phpactor.json"]
|
||||
install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usage/standalone.html)"
|
||||
|
||||
# Laravel projects only: hover, definition and completion inside
|
||||
# config('…'), route('…') and view('…'). Last: Intelephense keeps PHP hover,
|
||||
# rename and symbols. https://github.com/laravel/lsp
|
||||
[[language-servers]]
|
||||
name = "laravel-lsp"
|
||||
command = "laravel-lsp"
|
||||
args = ["--stdio"]
|
||||
root-markers = ["artisan"]
|
||||
require-root-marker = true
|
||||
install-hint = "composer global require laravel/lsp"
|
||||
|
||||
[debugger]
|
||||
adapter = "php-debug"
|
||||
command = "php-debug-adapter"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.38",
|
||||
"version": "0.1.39",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// The commit being prepared in the window the current command or event came
|
||||
/// from (the Commit tool window, or the Commit dialog while it is open): the
|
||||
/// changes it holds and its message.
|
||||
interface commit {
|
||||
use types.{error};
|
||||
|
||||
/// A file the commit changes.
|
||||
record commit-change {
|
||||
/// Absolute path of the working tree root of its repository.
|
||||
repository: string,
|
||||
/// Absolute path (after a rename, the new one).
|
||||
path: string,
|
||||
/// The path before a rename or copy.
|
||||
old-path: option<string>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or
|
||||
/// `type-changed` (more may be added).
|
||||
status: string,
|
||||
/// What the commit changes in the file, in `git diff` format against
|
||||
/// HEAD: only the changed blocks included in the commit. `none` for
|
||||
/// binary content.
|
||||
diff: option<string>,
|
||||
/// `diff` was cut at the size limit (1 MiB for all the changes).
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit right now.
|
||||
record commit-draft {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
revision: u64,
|
||||
message: string,
|
||||
/// The commit replaces the last one (Amend); `changes` are still
|
||||
/// against HEAD, so they don't include the last commit's own.
|
||||
amend: bool,
|
||||
changes: list<commit-change>,
|
||||
}
|
||||
|
||||
/// The commit being prepared, as Commit would make it: the included
|
||||
/// files and changed blocks only. Unsaved editors of the included files
|
||||
/// are saved first, as Commit does; nothing is staged or committed.
|
||||
/// `none` when the window's project has no repository. Fails with
|
||||
/// `invalid` outside a command or event of a window, and in a project that
|
||||
/// is not trusted. Requires `vcs.read`.
|
||||
pending-commit: func() -> result<option<commit-draft>, error>;
|
||||
/// Replaces the commit message, only if the draft is still at `revision`
|
||||
/// (else `stale`, and nothing changes): text the user typed meanwhile is
|
||||
/// never overwritten. The user reviews it and commits. Same windows as
|
||||
/// `pending-commit`. Requires `vcs.write`.
|
||||
set-commit-message: func(revision: u64, message: string) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// Before-commit checks (`[[contributes.commitChecks]]`), run when the user
|
||||
/// commits, after the IDE's own checks, for the checks enabled in Commit
|
||||
/// Options. Problems stop the commit and show beside the message until the
|
||||
/// user changes it or chooses Commit Anyway. Requires `vcs.read`.
|
||||
interface commit-checker {
|
||||
use types.{error, location};
|
||||
use invocation.{invocation};
|
||||
use commit.{commit-draft};
|
||||
|
||||
/// Something the user should look at before committing.
|
||||
record commit-problem {
|
||||
/// One sentence, shown after the check's title.
|
||||
message: string,
|
||||
/// Where it is, opened from the problem; `none` for the commit as a
|
||||
/// whole (its message, for instance).
|
||||
location: option<location>,
|
||||
}
|
||||
|
||||
/// Runs the declared check `check` on `draft`: no problems lets the commit
|
||||
/// go on. A call that fails or runs out of time is reported as a problem
|
||||
/// of the check.
|
||||
check: func(check: string, draft: commit-draft, ctx: borrow<invocation>) -> result<list<commit-problem>, error>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// What the pane beside the Services tree shows for a plugin's node.
|
||||
interface detail-types {
|
||||
/// A switch above a log; when on, `args` are appended and the program
|
||||
/// starts again.
|
||||
record log-toggle {
|
||||
id: string,
|
||||
label: string,
|
||||
args: list<string>,
|
||||
}
|
||||
|
||||
/// A program the IDE runs, its output followed in a log (ANSI stripped,
|
||||
/// the last 5000 lines) until it exits or the pane closes. Needs the
|
||||
/// plugin's `process:<program>` grant; runs in the project root.
|
||||
record process-log {
|
||||
program: string,
|
||||
args: list<string>,
|
||||
toggles: list<log-toggle>,
|
||||
}
|
||||
|
||||
/// The body of a tab.
|
||||
variant pane {
|
||||
/// A centred message: an empty state or why there is nothing to show.
|
||||
message(string),
|
||||
/// Read-only text the user can select and copy, at most 1 MiB.
|
||||
text(string),
|
||||
log(process-log),
|
||||
}
|
||||
|
||||
/// A tab of a node's pane; the IDE switches tabs without calling the plugin.
|
||||
record tab {
|
||||
id: string,
|
||||
label: string,
|
||||
body: pane,
|
||||
}
|
||||
}
|
||||
|
||||
/// The detail pane of the nodes of a plugin's `location = "services"` views.
|
||||
/// The nodes themselves, the toolbar and their events come from
|
||||
/// `view-provider` (`render` / `handle` of the view).
|
||||
interface detail-provider {
|
||||
use types.{error};
|
||||
use invocation.{invocation};
|
||||
use detail-types.{tab};
|
||||
|
||||
/// The tabs of node `key` of view `view` (at most 8), rendered when the
|
||||
/// node is selected and again after `views.refresh(view)`.
|
||||
detail: func(view: string, key: string, ctx: borrow<invocation>) -> result<list<tab>, error>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-diagnostics`): problems in the current,
|
||||
/// possibly unsaved document, for the languages declared with the
|
||||
/// `diagnostics` provider kind. Asked shortly after a document opens or
|
||||
/// changes, under the editor-provider budget; it can't ask the user.
|
||||
interface diagnostics-provider {
|
||||
use diagnostics.{diagnostic};
|
||||
|
||||
/// The document to check (the editor's text, unsaved edits included).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// The document's problems, at most 5,000; they replace what this
|
||||
/// provider reported before, and an empty list removes them.
|
||||
diagnostics: func(doc: document) -> list<diagnostic>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
|
||||
interface endpoints-provider {
|
||||
use types.{error, location};
|
||||
use invocation.{invocation};
|
||||
|
||||
/// One HTTP route.
|
||||
record endpoint {
|
||||
/// `none` accepts any HTTP method.
|
||||
method: option<string>,
|
||||
/// Path parameters use braces, e.g. `/photos/{id}`.
|
||||
path: string,
|
||||
/// Shown next to the route, e.g. `Rails`.
|
||||
framework: string,
|
||||
/// Display name, e.g. `PhotosController#show`.
|
||||
handler: option<string>,
|
||||
/// Where the route is declared, inside the project.
|
||||
location: location,
|
||||
}
|
||||
|
||||
/// The project's routes, complete. Called when the IDE first needs them and
|
||||
/// again after a file matching `files` changes; cached in between. Runs in
|
||||
/// the background: it can't ask the user.
|
||||
endpoints: func(ctx: borrow<invocation>) -> result<list<endpoint>, error>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
@@ -29,18 +29,25 @@ interface events {
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// Kinds: `file-opened`, `file-saved`, `files-changed` (batched disk changes
|
||||
/// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// 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, files-changed. Requires `events`.
|
||||
subscribe-paths: func(kind: string, globs: list<string>) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
/// call that began it may run for the background budget (60 s) instead of 5 s.
|
||||
/// call that began it may run for the background budget (60 s) instead of 5 s,
|
||||
/// and each `report` or `is-cancelled` renews that budget: work that keeps
|
||||
/// saying it is alive is not cut off, while work that goes quiet is.
|
||||
interface tasks {
|
||||
resource task {
|
||||
/// `done` of `total` units (`none`: indeterminate), with a short status.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-file-actions`): quick fixes and
|
||||
/// refactorings that create, rename or delete files, next to the text edits
|
||||
/// `providers.code-actions` offers. Declare the `code-actions` provider kind in
|
||||
/// `[[contributes.providers]]`; the host calls `code-actions` here with the same
|
||||
/// budget and lists the results with the others.
|
||||
interface file-actions {
|
||||
use types.{error, range, text-edit};
|
||||
|
||||
/// The document the actions are for (the editor's text while it is open).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// Text edits to one file.
|
||||
record file-edit {
|
||||
path: string,
|
||||
edits: list<text-edit>,
|
||||
}
|
||||
|
||||
/// A file change, applied before the action's text edits.
|
||||
variant file-operation {
|
||||
/// Fails when the file exists, unless `overwrite`.
|
||||
create(create-file),
|
||||
rename(rename-file),
|
||||
/// Deletes the file; undoing the action restores it.
|
||||
delete(delete-file),
|
||||
}
|
||||
|
||||
/// A new file with its text; missing parent directories are created.
|
||||
record create-file {
|
||||
path: string,
|
||||
text: string,
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
/// Moves `path` to `new-path`.
|
||||
record rename-file {
|
||||
path: string,
|
||||
new-path: string,
|
||||
}
|
||||
|
||||
/// The file to delete.
|
||||
record delete-file {
|
||||
path: string,
|
||||
}
|
||||
|
||||
/// A code action that changes files. Paths are absolute or relative to the
|
||||
/// project root, and stay inside it: a path outside disables the action.
|
||||
record file-action {
|
||||
title: string,
|
||||
/// `quickfix`, `refactor`, …
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
/// Applied first, in order, as one workspace edit with `edits`: if
|
||||
/// any operation or edit fails, nothing is applied, and one undo
|
||||
/// reverses all of it.
|
||||
operations: list<file-operation>,
|
||||
/// Applied after the operations; may target files they create.
|
||||
edits: list<file-edit>,
|
||||
/// Opened once the action is applied.
|
||||
open: option<string>,
|
||||
/// The plugin's own reference: with it and no `operations` or `edits`,
|
||||
/// they are computed by `resolve` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
/// Actions for `selection`, under the edit-path budget.
|
||||
code-actions: func(doc: document, selection: range) -> list<file-action>;
|
||||
/// The operations and edits of an action listed with `data` and nothing
|
||||
/// to apply, once the user picks it (the command budget applies, not the
|
||||
/// editor's 50 ms). The same four prompts as `providers.resolve-code-action`
|
||||
/// work here, in the editor's window and excluding the wait from the budget.
|
||||
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Files outside the open projects: the configuration of the program a
|
||||
/// plugin integrates with, a tool installed elsewhere, a licence file.
|
||||
///
|
||||
/// A plugin's own WASI has no preopened directory. Every path outside its
|
||||
/// own `plugin-dir` is checked against its `fs.read:<path>` (reading) or
|
||||
/// `fs.write:<path>`
|
||||
/// (writing; it doesn't imply reading) grants; without one covering the path a
|
||||
/// call returns `denied` naming the capability the manifest has to declare. A
|
||||
/// file inside a project is `workspace.project.read-file` / `write-file`.
|
||||
/// `path` is absolute, or starts with `~/` for the user's home.
|
||||
interface fs {
|
||||
use types.{error};
|
||||
use workspace.{file-kind, file-stat};
|
||||
|
||||
/// The plugin's persistent private directory, created owner-only (0700,
|
||||
/// owner-only ACL on Windows), removed on uninstall. Reading and writing
|
||||
/// under it needs no fs.* grant; files created there are owner-only.
|
||||
plugin-dir: func() -> result<string, error>;
|
||||
|
||||
/// A text file, decoded as the editor decodes it (BOM and encoding
|
||||
/// detection), at most 16 MiB. Unsaved editor contents win, as they do
|
||||
/// for `project.read-file`.
|
||||
read-file: func(path: string) -> result<string, error>;
|
||||
|
||||
/// The bytes of a file (images, class files, archives), at most 16 MiB.
|
||||
read-bytes: func(path: string) -> result<list<u8>, error>;
|
||||
|
||||
/// Metadata of an existing path, as `project.stat` reports it; a missing
|
||||
/// path is `not-found`.
|
||||
stat: func(path: string) -> result<file-stat, error>;
|
||||
|
||||
/// An entry of a directory.
|
||||
record dir-entry {
|
||||
/// The entry's name, without its directory.
|
||||
name: string,
|
||||
kind: file-kind,
|
||||
}
|
||||
|
||||
/// The entries directly under a directory, sorted by name, at most 4096
|
||||
/// (like `find-files`); a file is `invalid`.
|
||||
read-dir: func(path: string) -> result<list<dir-entry>, error>;
|
||||
|
||||
/// Replaces the text of a file, or creates it, at most 16 MiB: in one step
|
||||
/// (a reader never sees half of it), keeping the file's encoding and BOM;
|
||||
/// a new file is UTF-8. `invalid` when the text can't be written in that
|
||||
/// encoding, the directory is missing, the path is a directory, or an
|
||||
/// editor holds unsaved changes for the file (save them first).
|
||||
write-file: func(path: string, text: string) -> result<_, error>;
|
||||
|
||||
/// Replaces the bytes of a file, or creates it, at most 16 MiB, in one
|
||||
/// step; fails like `write-file`.
|
||||
write-bytes: func(path: string, bytes: list<u8>) -> result<_, error>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
@@ -33,9 +33,23 @@ interface ui {
|
||||
text(string),
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the plugin's commands.
|
||||
record notification-action {
|
||||
/// Up to 256 bytes, one line.
|
||||
label: string,
|
||||
/// The id of a command the plugin declares.
|
||||
command: string,
|
||||
/// Passed to the command as its argument (up to 64 KiB).
|
||||
argument: option<string>,
|
||||
}
|
||||
|
||||
/// Shows a notification balloon in the window the current command or
|
||||
/// event came from (every window when there is none).
|
||||
show-notification: func(level: notification-level, title: string, body: string);
|
||||
/// `show-notification` with up to three action buttons. Choosing one
|
||||
/// dismisses the balloon and runs the command in its window. An invalid
|
||||
/// action or an undeclared command fails the call and shows nothing.
|
||||
notify: func(level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
|
||||
/// Shows or updates the plugin's status bar item `id` in every window.
|
||||
set-status-item: func(id: string, item: status-item);
|
||||
remove-status-item: func(id: string);
|
||||
@@ -51,24 +65,36 @@ interface ui {
|
||||
copy-to-clipboard: func(text: string) -> result<_, error>;
|
||||
/// Opens an `http://` or `https://` URL in the user's default browser.
|
||||
open-url: func(url: string) -> result<_, error>;
|
||||
/// Shows a list in the window the current command or event came from and
|
||||
/// waits for the key of the chosen item, or `none` when the user dismisses
|
||||
/// it. Needs no capability. Returns `invalid` outside a command or event of
|
||||
/// a window (including in editor providers). Waiting doesn't count against
|
||||
/// the call's budget.
|
||||
/// Shows a list and waits for the chosen key, or `none` when dismissed.
|
||||
/// Works in a command/event of a window, or while resolving a selected code
|
||||
/// or file action in its editor's window; `invalid` otherwise.
|
||||
/// Needs no capability. Waiting doesn't count against the call's budget.
|
||||
pick: func(title: string, items: list<pick-item>) -> result<option<string>, error>;
|
||||
/// Native folder chooser; an absolute path, or `none` when dismissed.
|
||||
/// Only from a command or event of a window; waiting is excluded from the budget.
|
||||
pick-directory: func(title: string, initial: option<string>) -> result<option<string>, error>;
|
||||
/// Like `pick`, with any number of items chosen.
|
||||
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
|
||||
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
|
||||
/// Same rules as `pick`.
|
||||
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
|
||||
/// Asks for text over several lines, pre-filled with `value`; `none` when
|
||||
/// dismissed. Only from a command or event of a window; the dialog confirms
|
||||
/// on Cmd/Ctrl+Enter.
|
||||
input-multiline: func(prompt: string, value: option<string>) -> result<option<string>, error>;
|
||||
/// Asks for confirmation with OK and Cancel: `true` on OK, `false` on
|
||||
/// Cancel or Escape. Same rules as `pick`.
|
||||
confirm: func(title: string, body: string) -> result<bool, error>;
|
||||
/// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …); it
|
||||
/// follows a change of language. For the text the plugin builds itself:
|
||||
/// the strings of its manifest and views are translated from
|
||||
/// `locales/<tag>.json`.
|
||||
locale: func() -> string;
|
||||
}
|
||||
|
||||
/// Persistent key-value storage of the plugin (survives restarts and idle
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
|
||||
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
|
||||
interface storage {
|
||||
use types.{error};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-hover`): Markdown added to the hover of
|
||||
/// documents of the languages declared with the `hover` provider kind,
|
||||
/// including languages the plugin doesn't contribute. It shows below the
|
||||
/// language server's hover, under the edit-path budget.
|
||||
interface hover-provider {
|
||||
use types.{position, range};
|
||||
|
||||
/// The document hovered (the editor's text while it is open).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// What the plugin adds to the hover.
|
||||
record hover-content {
|
||||
/// At most 64 KiB.
|
||||
markdown: string,
|
||||
/// The text the hover describes; used when the server names none.
|
||||
range: option<range>,
|
||||
}
|
||||
|
||||
/// The hover at `at`, or `none` to add nothing.
|
||||
hover: func(doc: document, at: position) -> option<hover-content>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Cross-file associations a plugin publishes for the files it declares under
|
||||
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
|
||||
/// the languages declared with the `inlay-hints` provider kind, including
|
||||
/// languages the plugin doesn't contribute. They are shown next to the
|
||||
/// language server's hints.
|
||||
interface inlay-hints-provider {
|
||||
use types.{position, range};
|
||||
|
||||
/// The document the hints are for (the editor's text while it is open).
|
||||
record document {
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// A hint shown at `position`, before the character there.
|
||||
record inlay-hint {
|
||||
/// UTF-16 position (0-based line and column).
|
||||
position: position,
|
||||
/// The text shown, e.g. `GET /orders` or `: string`.
|
||||
label: string,
|
||||
/// `type` or `parameter` follow the Inlay Hints settings of that kind;
|
||||
/// anything else counts as `other`.
|
||||
kind: string,
|
||||
/// Shown when the pointer rests on the hint.
|
||||
tooltip: option<string>,
|
||||
/// A space before the label.
|
||||
padding-left: bool,
|
||||
/// A space after the label.
|
||||
padding-right: bool,
|
||||
}
|
||||
|
||||
/// The hints within `range` (the visible part of the document), under the
|
||||
/// edit-path budget. Hints outside `range` are dropped; at most 10,000 per
|
||||
/// call.
|
||||
inlay-hints: func(doc: document, range: range) -> list<inlay-hint>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
@@ -75,8 +75,10 @@ interface secrets {
|
||||
is-set: func(name: string) -> bool;
|
||||
}
|
||||
|
||||
/// One-off runs of programs granted with `process:<program>` (the file the
|
||||
/// user approved, checked before every run).
|
||||
/// Runs of programs granted with `process:<program>` (the file the user
|
||||
/// approved, checked before every run): once and awaited with `run`, once
|
||||
/// with an environment with `run-command`, or kept running behind a `child`
|
||||
/// handle started with `start`.
|
||||
interface process {
|
||||
use types.{error};
|
||||
use workspace.{project};
|
||||
@@ -89,8 +91,56 @@ interface process {
|
||||
stderr: string,
|
||||
}
|
||||
|
||||
/// What to run (API 1.3). `env` only adds variables: an existing name in
|
||||
/// the prepared child environment (including the login-shell PATH) is an
|
||||
/// error naming that key; nothing is replaced or removed. Names compare
|
||||
/// case-insensitively on Windows. At most 64 entries, nonempty names of
|
||||
/// at most 256 bytes without '=' or NUL, values at most 4 KiB without NUL.
|
||||
record command {
|
||||
program: string,
|
||||
args: list<string>,
|
||||
env: list<tuple<string, string>>,
|
||||
}
|
||||
|
||||
/// What a kept-running program printed since the last call, and its exit
|
||||
/// code once it ended (`none` while it still runs).
|
||||
record chunk {
|
||||
/// Up to 1 MiB per stream; unpolled output keeps the last 1 MiB.
|
||||
/// `poll` takes up to `max-bytes`, or one whole character when that
|
||||
/// limit is smaller. Incomplete characters wait for the next read.
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
status: option<s32>,
|
||||
}
|
||||
|
||||
/// A program left running. Dropping the handle kills it if it still
|
||||
/// runs, so a plugin cannot leak one; a plugin has at most 8 at a time
|
||||
/// (the ninth `start` is `invalid`). They also end with the plugin:
|
||||
/// stopping, disabling or reclaiming it kills what it started.
|
||||
resource child {
|
||||
/// What it printed since the last call, and its status once it ends.
|
||||
poll: func(max-bytes: u32) -> result<chunk, error>;
|
||||
/// Waits up to `timeout-ms` for it to end (0 only checks), then
|
||||
/// reports what it printed since the last call.
|
||||
wait: func(timeout-ms: u32) -> result<chunk, error>;
|
||||
/// Writes to its stdin; fails once it ended or stdin was closed.
|
||||
write: func(text: string) -> result<_, error>;
|
||||
/// Closes stdin; fails once the program ended.
|
||||
close-stdin: func() -> result<_, error>;
|
||||
/// Kills it and reaps it, so its status is available afterwards.
|
||||
kill: func() -> result<_, error>;
|
||||
}
|
||||
|
||||
/// Runs `program` with `args` in `cwd`'s root (else the plugin's data
|
||||
/// directory) and waits; cancelled with the task or call. Call it inside
|
||||
/// a task for anything slow.
|
||||
run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// The same as `run`, with append-only environment variables (API 1.3).
|
||||
run-command: func(cmd: command, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
|
||||
/// Starts `cmd` with piped stdin and stdout/stderr, and returns the
|
||||
/// handle to it without waiting (API 1.3). Closing the invoking window
|
||||
/// also ends its processes.
|
||||
start: func(cmd: command, cwd: option<borrow<project>>) -> result<child, error>;
|
||||
}
|
||||
@@ -3,20 +3,23 @@
|
||||
/// API"); a 2.0 keeps 1.0 running through an adapter in the host.
|
||||
///
|
||||
/// A plugin is a WebAssembly component targeting the `plugin` world (or
|
||||
/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment
|
||||
/// or processes beyond WASI's clocks and randomness; everything else goes
|
||||
/// through the imports below, gated by the capabilities granted in
|
||||
/// `plugin.toml` (a missing one returns `error::denied`).
|
||||
package openidea:[email protected];
|
||||
/// `plugin-syntax`). It runs sandboxed: no file system, network, environment
|
||||
/// or processes beyond WASI's clocks and randomness; files are read through
|
||||
/// `fs` and `workspace`, and everything else goes through the imports below,
|
||||
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
|
||||
/// `error::denied`).
|
||||
package openidea:[email protected];
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
import ui;
|
||||
import storage;
|
||||
import fs;
|
||||
import workspace;
|
||||
import editor;
|
||||
import invocation;
|
||||
import vcs;
|
||||
import commit;
|
||||
import events;
|
||||
import tasks;
|
||||
import diagnostics;
|
||||
@@ -56,6 +59,43 @@ world plugin-indexing {
|
||||
export document-indexer;
|
||||
}
|
||||
|
||||
/// A plugin with semantic tokens (`semantic-tokens` under `[[contributes.providers]]`).
|
||||
world plugin-semantic-tokens {
|
||||
include plugin;
|
||||
export semantic-tokens-provider;
|
||||
}
|
||||
|
||||
/// A plugin adding to hovers (`hover` under `[[contributes.providers]]`).
|
||||
world plugin-hover {
|
||||
include plugin;
|
||||
export hover-provider;
|
||||
}
|
||||
|
||||
/// A plugin with inlay hints (`inlay-hints` under `[[contributes.providers]]`).
|
||||
world plugin-inlay-hints {
|
||||
include plugin;
|
||||
export inlay-hints-provider;
|
||||
}
|
||||
|
||||
/// A plugin whose views show in the Services tool window
|
||||
/// (`location = "services"`): their nodes' detail pane.
|
||||
world plugin-details {
|
||||
include plugin;
|
||||
export detail-provider;
|
||||
}
|
||||
|
||||
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
|
||||
world plugin-commit-checks {
|
||||
include plugin;
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// A plugin with file-changing code actions (`file-actions`, next to `[[contributes.providers]]`).
|
||||
world plugin-file-actions {
|
||||
include plugin;
|
||||
export file-actions;
|
||||
}
|
||||
|
||||
/// Only the `syntax-provider` export: guests generate it next to `plugin`
|
||||
/// (the component tools merge both into one component).
|
||||
world syntax {
|
||||
@@ -76,3 +116,55 @@ world views-provider {
|
||||
world indexing {
|
||||
export document-indexer;
|
||||
}
|
||||
|
||||
/// Only the `semantic-tokens-provider` export, generated next to `plugin` like `syntax`.
|
||||
world semantic-tokens {
|
||||
export semantic-tokens-provider;
|
||||
}
|
||||
|
||||
/// Only the `hover-provider` export, generated next to `plugin` like `syntax`.
|
||||
world hover {
|
||||
export hover-provider;
|
||||
}
|
||||
|
||||
/// Only the `inlay-hints-provider` export, generated next to `plugin` like `syntax`.
|
||||
world inlay-hints {
|
||||
export inlay-hints-provider;
|
||||
}
|
||||
|
||||
/// Only the `detail-provider` export, generated next to `plugin` like `syntax`.
|
||||
world details {
|
||||
export detail-provider;
|
||||
}
|
||||
|
||||
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
|
||||
world commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// Only the `file-actions` export, generated next to `plugin` like `syntax`.
|
||||
world file-actions-provider {
|
||||
export file-actions;
|
||||
}
|
||||
|
||||
/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`).
|
||||
world plugin-endpoints {
|
||||
include plugin;
|
||||
export endpoints-provider;
|
||||
}
|
||||
|
||||
/// Only the `endpoints-provider` export, generated next to `plugin` like `syntax`.
|
||||
world endpoints {
|
||||
export endpoints-provider;
|
||||
}
|
||||
|
||||
/// A plugin with diagnostics under `[[contributes.providers]]`.
|
||||
world plugin-diagnostics {
|
||||
include plugin;
|
||||
export diagnostics-provider;
|
||||
}
|
||||
|
||||
/// `diagnostics` is already the released host interface name.
|
||||
world editor-diagnostics {
|
||||
export diagnostics-provider;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
@@ -75,6 +75,9 @@ interface providers {
|
||||
code-actions: func(doc: document, selection: range) -> list<code-action>;
|
||||
/// The edits of an action `code-actions` returned with `data`, when the
|
||||
/// user picks it (the command budget applies, not the editor's 50 ms).
|
||||
/// `ui.pick`, `pick-many`, `input` and `confirm` ask in the editor's window;
|
||||
/// waiting is excluded from the budget. Other command/event-only APIs
|
||||
/// remain unavailable.
|
||||
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
|
||||
definition: func(doc: document, at: position) -> list<location>;
|
||||
line-markers: func(doc: document) -> list<line-marker>;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
|
||||
/// documents of the languages declared with the `semantic-tokens` provider
|
||||
/// kind, including languages the plugin doesn't contribute. The tokens join
|
||||
/// the language server's and win where they overlap one.
|
||||
interface semantic-tokens-provider {
|
||||
use types.{range};
|
||||
|
||||
/// The document to colour (the editor's text while it is open).
|
||||
record document {
|
||||
/// Absolute path.
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// A token: part of one line.
|
||||
record semantic-token {
|
||||
range: range,
|
||||
/// An LSP token type in kebab-case (`method`, `property`, `type`, …).
|
||||
kind: string,
|
||||
/// LSP token modifiers in kebab-case (`static`, `deprecated`, …).
|
||||
modifiers: list<string>,
|
||||
}
|
||||
|
||||
/// The document's tokens, at most 65,536; asked again after each edit,
|
||||
/// under the edit-path budget.
|
||||
semantic-tokens: func(doc: document) -> list<semantic-token>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
@@ -123,6 +123,7 @@ interface view-types {
|
||||
/// What a plugin's code can ask of its views.
|
||||
interface views {
|
||||
use types.{error};
|
||||
use detail-types.{process-log};
|
||||
|
||||
/// The view (or every custom editor of that id) shows outdated content:
|
||||
/// the IDE renders it again.
|
||||
@@ -136,9 +137,18 @@ interface views {
|
||||
/// `program` (pinned by a `process:<program>` grant) with `args` in the
|
||||
/// project root: long-running output (logs) and interactive sessions.
|
||||
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
|
||||
/// Opens a read-only log tab in the editor area of the window the call
|
||||
/// came from, following `log`'s program (pinned by a `process:<program>`
|
||||
/// grant) in the project root, its toggles above the output: the tab a
|
||||
/// web view opens with `open-log-tab`. Reopening `key` focuses that tab.
|
||||
/// Only from a command or event of a window; `title` at most 256
|
||||
/// characters, `key` 128, at most 32 toggles.
|
||||
open-log-tab: func(key: string, title: string, log: process-log) -> result<_, error>;
|
||||
/// Opens `path` (project-relative or absolute, inside the project) in an
|
||||
/// editor of the window the call came from, the caret at `line` and
|
||||
/// `column` (0-based; UTF-16 column): a view's links to code.
|
||||
/// An absolute path inside the plugin's own fs.plugin-dir also works,
|
||||
/// without project.read or an open project, from a command or event.
|
||||
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,43 @@
|
||||
package openidea:plugin@1.2.0;
|
||||
package openidea:plugin@1.3.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error};
|
||||
use types.{error, location};
|
||||
|
||||
/// Requests File > Open in this command/event's window (or a new window),
|
||||
/// including the usual project trust prompt. Needs no capability.
|
||||
/// `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,
|
||||
directory,
|
||||
symlink,
|
||||
other,
|
||||
}
|
||||
|
||||
/// Metadata of a path inside a project, as `stat` reports it.
|
||||
record file-stat {
|
||||
kind: file-kind,
|
||||
/// Bytes, for a file.
|
||||
size: u64,
|
||||
/// Seconds since the Unix epoch; `none` when the file system does
|
||||
/// not track it.
|
||||
modified: option<u64>,
|
||||
}
|
||||
|
||||
/// A symbol in the host's current in-memory project index (new in 1.3).
|
||||
record symbol-info {
|
||||
name: string,
|
||||
/// Host symbol kind, in camelCase (`class`, `method`, `enumMember`, …).
|
||||
kind: string,
|
||||
container: option<string>,
|
||||
location: location,
|
||||
}
|
||||
|
||||
/// A project open in the IDE.
|
||||
resource project {
|
||||
@@ -9,12 +45,40 @@ interface workspace {
|
||||
root: func() -> result<string, error>;
|
||||
/// Reads a UTF-8 text file inside the project (absolute, or relative
|
||||
/// to the root); paths leaving the root are refused. Unsaved editor
|
||||
/// contents win over the disk. Requires `project.read`.
|
||||
/// contents win over the disk. A missing path is `not-found`.
|
||||
/// Requires `project.read`.
|
||||
read-file: func(path: string) -> result<string, error>;
|
||||
/// Replaces the text of a file inside the project (absolute, or
|
||||
/// relative to the root), or creates it with missing parent
|
||||
/// directories; paths leaving the root are refused. Written in one
|
||||
/// step (a reader never sees half of it), keeping the file's encoding
|
||||
/// and BOM; a new file is UTF-8. An editor holding unsaved changes for
|
||||
/// the file makes it `invalid` (save them first). Requires
|
||||
/// `project.write`.
|
||||
write-file: func(path: string, text: string) -> result<_, error>;
|
||||
/// Whether `path` (absolute, or relative to the root) names an
|
||||
/// existing file or directory — including hidden and ignored ones,
|
||||
/// without reading any content. Paths leaving the root are refused.
|
||||
/// Requires `project.read`.
|
||||
exists: func(path: string) -> result<bool, error>;
|
||||
/// Metadata of an existing path inside the project (absolute, or
|
||||
/// relative to the root); a missing path is `not-found` and paths
|
||||
/// leaving the root are refused. Requires `project.read`.
|
||||
stat: func(path: string) -> result<file-stat, error>;
|
||||
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
|
||||
/// at most `limit`, as absolute paths. Ignored and excluded files are
|
||||
/// skipped. Requires `project.read`.
|
||||
find-files: func(glob: string, limit: u32) -> result<list<string>, error>;
|
||||
/// Fuzzy symbol search (CamelHump and container-qualified queries) over the
|
||||
/// current index only; never starts or waits for a language server. Results
|
||||
/// may be incomplete while indexing and ranges reflect the indexed text,
|
||||
/// not necessarily unsaved edits. Empty queries return no symbols. Queries
|
||||
/// over 256 UTF-8 bytes are invalid; limit is capped at 1000 (zero is
|
||||
/// empty). Only paths readable under the root are returned; external
|
||||
/// content roots are excluded. Cancellation returns cancelled; exceeding
|
||||
/// the current call's deadline fails. A closed project is not-found.
|
||||
/// Requires `project.read`.
|
||||
symbols: func(query: string, limit: u32) -> result<list<symbol-info>, error>;
|
||||
}
|
||||
|
||||
/// Projects open in any window. Requires `project.read`.
|
||||
|
||||
@@ -37,6 +37,7 @@ tracing-subscriber.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
ide-core.workspace = true
|
||||
ide-cli.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-clipboard.workspace = true
|
||||
ide-fonts.workspace = true
|
||||
@@ -82,8 +83,8 @@ block2 = "0.6"
|
||||
# Plugin secrets in the login keychain.
|
||||
security-framework = "3.7"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# The `openidea` command, linked onto PATH by "Install 'openidea' Command".
|
||||
# Runs the app executable of this bundle in CLI mode (src-tauri/src/cli.rs).
|
||||
self=$0
|
||||
while [ -L "$self" ]; do
|
||||
link=$(readlink "$self")
|
||||
case $link in
|
||||
/*) self=$link ;;
|
||||
*) self=$(dirname "$self")/$link ;;
|
||||
esac
|
||||
done
|
||||
contents=$(cd "$(dirname "$self")/../.." && pwd -P) || exit 1
|
||||
exec "$contents/MacOS/openidea" --cli "$@"
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The `openidea` command: this executable in CLI mode (started by the bundle's
|
||||
//! launcher script with [`CLI_FLAG`]), the socket the app answers it on, and
|
||||
//! installing the command on `PATH`.
|
||||
|
||||
use ide_cli::args::{self, Command};
|
||||
use ide_cli::{Request, Response, socket};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// First argument from `Contents/Resources/bin/openidea`.
|
||||
const CLI_FLAG: &str = "--cli";
|
||||
/// How long a starting app has to open its socket.
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Runs as the `openidea` command when started with [`CLI_FLAG`]; returns the exit code.
|
||||
pub fn run(identifier: &str) -> Option<i32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
if args.next().as_deref() != Some(CLI_FLAG) {
|
||||
return None;
|
||||
}
|
||||
let args: Vec<String> = args.collect();
|
||||
#[cfg(windows)]
|
||||
ide_cli::attach_console();
|
||||
Some(match client(identifier, &args) {
|
||||
Ok(()) => 0,
|
||||
Err(message) => {
|
||||
eprintln!("openidea: {message}");
|
||||
1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn client(identifier: &str, args: &[String]) -> Result<(), String> {
|
||||
let cwd = std::env::current_dir().map_err(|e| format!("no working directory: {e}"))?;
|
||||
let command = args::parse(args, &cwd)?;
|
||||
let diff = matches!(command, Command::Diff(_));
|
||||
let targets = match command {
|
||||
Command::Help => {
|
||||
print!("{}", args::USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
Command::Version => {
|
||||
println!("OpenIDEA {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
Command::Open(targets) | Command::Diff(targets) => targets,
|
||||
};
|
||||
let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?;
|
||||
let request = Request { diff, ..Request::open(targets) };
|
||||
let response = match socket::send(&socket, &request) {
|
||||
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
|
||||
start_app()?;
|
||||
send_when_started(&socket, &request)?
|
||||
}
|
||||
sent => sent.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?,
|
||||
};
|
||||
match response {
|
||||
Response::Ok => Ok(()),
|
||||
Response::Error { message } => Err(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the app detached from this terminal: through Launch Services for a
|
||||
/// bundle, else (a development build) this executable without [`CLI_FLAG`].
|
||||
fn start_app() -> Result<(), String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = ide_cli::install::stable_executable(&exe, image.as_deref()).map_err(|e| e.to_string())?;
|
||||
let mut command = match app_bundle(&exe) {
|
||||
Some(bundle) => {
|
||||
let mut open = std::process::Command::new("/usr/bin/open");
|
||||
open.arg("-a").arg(bundle);
|
||||
open
|
||||
}
|
||||
None => std::process::Command::new(&exe),
|
||||
};
|
||||
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
ide_core::process::host_env(&mut command);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
||||
#[cfg(windows)]
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut command, 0x0000_0008 | 0x0000_0200);
|
||||
command.spawn().map(drop).map_err(|e| format!("cannot start OpenIDEA: {e}"))
|
||||
}
|
||||
|
||||
fn send_when_started(socket: &Path, request: &Request) -> Result<Response, String> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match socket::send(socket, request) {
|
||||
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
|
||||
if start.elapsed() > START_TIMEOUT {
|
||||
return Err("OpenIDEA did not start in time".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
sent => return sent.map_err(|e| format!("cannot reach OpenIDEA: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `….app` of the bundle executable `exe` (`….app/Contents/MacOS/openidea`).
|
||||
fn app_bundle(exe: &Path) -> Option<PathBuf> {
|
||||
let bundle = exe.parent()?.parent()?.parent()?;
|
||||
(cfg!(target_os = "macos") && bundle.extension().is_some_and(|e| e == "app")).then(|| bundle.to_path_buf())
|
||||
}
|
||||
|
||||
/// Answers the `openidea` command for this app: its targets open like paths from Finder.
|
||||
pub fn serve(app: &AppHandle, identifier: &str) {
|
||||
let Some(socket) = socket::socket_path(identifier) else { return };
|
||||
let app = app.clone();
|
||||
let served = socket::serve(&socket, move |request| {
|
||||
let handle = app.clone();
|
||||
match app.run_on_main_thread(move || {
|
||||
if request.diff {
|
||||
let mut targets = request.targets.into_iter();
|
||||
if let (Some(left), Some(right)) = (targets.next(), targets.next()) {
|
||||
crate::windows::open_diff_files(
|
||||
&handle,
|
||||
crate::windows::DiffFilesLaunch { left: left.path.into(), right: right.path.into() },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
crate::windows::open_targets(&handle, request.targets);
|
||||
}
|
||||
}) {
|
||||
Ok(()) => Response::Ok,
|
||||
Err(e) => Response::Error { message: format!("OpenIDEA cannot open windows: {e}") },
|
||||
}
|
||||
});
|
||||
if let Err(e) = served {
|
||||
tracing::warn!("cannot listen for the openidea command on {}: {e}", socket.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn install_error(e: std::io::Error) -> IdeError {
|
||||
if e.kind() == ErrorKind::Interrupted { IdeError::cancelled() } else { IdeError::internal(e.to_string()) }
|
||||
}
|
||||
|
||||
/// Links the `openidea` command onto `PATH`; returns the link. Blocks while the
|
||||
/// administrator password prompt is up.
|
||||
pub fn install() -> IdeResult<crate::commands::window::CliInstallation> {
|
||||
let result = ide_cli::install::install_current().map_err(install_error)?;
|
||||
Ok(crate::commands::window::CliInstallation {
|
||||
path: result.path.into(),
|
||||
path_directory: result.path_directory.map(Into::into),
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes every installed `openidea` command link; returns them.
|
||||
pub fn uninstall() -> IdeResult<Vec<IpcPath>> {
|
||||
let links = ide_cli::install::uninstall_current().map_err(install_error)?;
|
||||
Ok(links.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundle_of_the_app_executable() {
|
||||
let exe = Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea");
|
||||
let expected = cfg!(target_os = "macos").then(|| PathBuf::from("/Applications/OpenIDEA.app"));
|
||||
assert_eq!(app_bundle(exe), expected);
|
||||
assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window_group::window_group_close,
|
||||
window_group::window_group_close_tabs,
|
||||
window_group::window_group_cancel_close,
|
||||
window_group::window_group_before_close,
|
||||
window_group::window_group_reorder,
|
||||
window_group::window_group_restore,
|
||||
app::light_edit_open,
|
||||
@@ -446,6 +445,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::menu_model,
|
||||
window::menu_set_disabled,
|
||||
window::app_log_location,
|
||||
window::app_cli_install,
|
||||
window::app_cli_uninstall,
|
||||
window::app_build_info,
|
||||
window::app_check_update,
|
||||
window::app_startup_update_check,
|
||||
|
||||
@@ -9,7 +9,7 @@ use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, State, WebviewWindow};
|
||||
use tauri::{AppHandle, State, WebviewWindow, Window};
|
||||
|
||||
/// Opens `path` in a new window (`new_window`) or tells the caller to open it
|
||||
/// itself (`current`). A project already open in some window is focused instead.
|
||||
@@ -193,17 +193,10 @@ 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.
|
||||
/// Whether this window is in native full screen (on macOS, also a tab of a fullscreen group).
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -218,8 +211,18 @@ pub struct CloseContext {
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_close_context(app: AppHandle, windows: State<'_, WindowsState>) -> CloseContext {
|
||||
CloseContext { quitting: windows.quitting(), windows: windows::project_window_labels(&app).len() as u32 }
|
||||
pub fn window_close_context(app: AppHandle, window: Window, windows: State<'_, WindowsState>) -> CloseContext {
|
||||
let all = windows::project_window_labels(&app);
|
||||
let pending: Vec<String> = windows::sorted_labels(
|
||||
windows.pending_closes().iter().map(String::as_str).filter(|l| all.iter().any(|a| a == l)),
|
||||
);
|
||||
// Members of a tab group asking to close together are one window closing: the first of them
|
||||
// is the last window left (it returns to the welcome screen); the others just close.
|
||||
let count = match pending.first() {
|
||||
Some(keeper) if pending.len() > 1 && keeper == window.label() => all.len() - pending.len() + 1,
|
||||
_ => all.len(),
|
||||
};
|
||||
CloseContext { quitting: windows.quitting(), windows: count as u32 }
|
||||
}
|
||||
|
||||
/// Quits: closes every window, each of which runs its own unsaved-changes check.
|
||||
@@ -310,6 +313,27 @@ pub fn app_log_location() -> IdeResult<LogLocation> {
|
||||
Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) })
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CliInstallation {
|
||||
pub path: ide_core::IpcPath,
|
||||
pub path_directory: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// Installs the `openidea` command for this user.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_cli_install() -> IdeResult<CliInstallation> {
|
||||
tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Removes the installed `openidea` command; returns the links removed.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_cli_uninstall() -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
tauri::async_runtime::spawn_blocking(crate::cli::uninstall).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildInfo {
|
||||
|
||||
@@ -55,20 +55,6 @@ pub fn window_group_cancel_close(window: WebviewWindow) {
|
||||
groups::cancel_close(window.app_handle(), window.label());
|
||||
}
|
||||
|
||||
/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space
|
||||
/// to the tab that takes over.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return groups::before_close(window.app_handle(), window.label()).await;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = window;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_group_reorder(window: WebviewWindow, labels: Vec<String>) -> IdeResult<()> {
|
||||
|
||||
+17
-2
@@ -4,6 +4,7 @@
|
||||
//! adapter from IPC to a backend crate; no domain logic lives here.
|
||||
|
||||
mod application_focus;
|
||||
mod cli;
|
||||
mod commands;
|
||||
#[cfg(debug_assertions)]
|
||||
mod dev;
|
||||
@@ -72,6 +73,10 @@ pub fn run() {
|
||||
let context = tauri::generate_context!();
|
||||
#[cfg(debug_assertions)]
|
||||
let context = dev::configure(context);
|
||||
// The `openidea` command: answer and exit, no UI.
|
||||
if let Some(code) = cli::run(&context.config().identifier) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
// Before the first window: one taskbar identity for every launch path.
|
||||
jump_list::set_app_id(&context.config().identifier);
|
||||
logging::init(&context.config().identifier);
|
||||
@@ -190,8 +195,13 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::intercept_system_quit(app.handle());
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
macos::prepare_window_tabbing(mtm);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
application_focus::observe(app.handle());
|
||||
app.manage(menu_state);
|
||||
cli::serve(app.handle(), &app.config().identifier);
|
||||
if let Some(paths) = setup_opens.lock().take() {
|
||||
windows::open_paths_at_launch(app.handle(), paths);
|
||||
}
|
||||
@@ -201,8 +211,13 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
#[cfg(target_os = "linux")]
|
||||
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_window_state::save_before_close(window.app_handle());
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
w.close_requested(window.label());
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Focused(true) => {
|
||||
application_focus::changed(window.app_handle(), true);
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
|
||||
+59
-350
@@ -1,15 +1,13 @@
|
||||
//! macOS window tab preferences, applied to application-drawn project tabs.
|
||||
//! macOS project tabs: native window tab groups behind the page's own tab bar.
|
||||
use crate::windows::{Launch, WindowsState};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_workspace::window_groups::{TabbingPreference, should_group_new_project};
|
||||
use objc2::{
|
||||
MainThreadMarker,
|
||||
rc::Retained,
|
||||
runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel},
|
||||
runtime::{AnyClass, AnyObject, Imp, Sel},
|
||||
};
|
||||
use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
|
||||
use std::cell::Cell;
|
||||
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
|
||||
@@ -47,33 +45,13 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Opt
|
||||
let host = app.state::<crate::state::AppState>();
|
||||
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
|
||||
});
|
||||
// A group member other than its fullscreen host is not itself in native fullscreen.
|
||||
let fullscreen = active.as_ref().is_some_and(|w| crate::window_groups::in_fullscreen(app, w));
|
||||
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
|
||||
let light_edit = launch.project.is_none() && launch.remote.is_none();
|
||||
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
|
||||
.then_some(active)
|
||||
.flatten())
|
||||
}
|
||||
|
||||
struct FullscreenObserver(Retained<ProtocolObject<dyn NSObjectProtocol>>);
|
||||
|
||||
impl Drop for FullscreenObserver {
|
||||
fn drop(&mut self) {
|
||||
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
|
||||
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Fullscreen hosts of window groups, watched for leaving fullscreen.
|
||||
static FULLSCREEN_HOSTS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
/// Stops watching `label`; true when it hosted a fullscreen window group.
|
||||
pub fn forget_fullscreen_host(label: &str) -> bool {
|
||||
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.remove(label)).is_some()
|
||||
}
|
||||
|
||||
/// Runs `f` on the main thread before returning, so it takes effect before what the caller does next.
|
||||
fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Send + 'static) -> tauri::Result<R> {
|
||||
if MainThreadMarker::new().is_some() {
|
||||
@@ -86,182 +64,74 @@ fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Se
|
||||
rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage)
|
||||
}
|
||||
|
||||
/// The native `toggleFullScreen:`, bypassing the group handling installed on the window class.
|
||||
pub fn native_toggle_fullscreen(window: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = window.clone();
|
||||
on_main(window, move || {
|
||||
let (Ok(pointer), Some(native)) = (target.ns_window(), NATIVE_TOGGLE_FULLSCREEN.get()) else { return };
|
||||
unsafe { native(pointer.cast(), objc2::sel!(toggleFullScreen:), std::ptr::null_mut()) };
|
||||
})
|
||||
/// Project tabs are native window tabs, so a group shares one frame and enters full screen as one
|
||||
/// window, with the system's window buttons on whichever tab is shown. The tab bar is the page's
|
||||
/// own; AppKit's is hidden, and windows never join a tab group on their own.
|
||||
pub fn prepare_window_tabbing(mtm: MainThreadMarker) {
|
||||
NSWindow::setAllowsAutomaticWindowTabbing(false, mtm);
|
||||
}
|
||||
|
||||
/// A window group in fullscreen shares its host's Space: every other member joins it as an
|
||||
/// auxiliary window over the host's frame, without a frame, window buttons or dragging, like the host.
|
||||
/// Without a host the window is an ordinary one again.
|
||||
pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWindow>) -> tauri::Result<()> {
|
||||
use objc2_app_kit::{NSWindowButton, NSWindowCollectionBehavior as B};
|
||||
let target = window.clone();
|
||||
let host = host.cloned();
|
||||
on_main(window, move || {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = unsafe { &*pointer.cast::<NSWindow>() };
|
||||
let frame =
|
||||
host.and_then(|host| host.ns_window().ok()).map(|host| unsafe { &*host.cast::<NSWindow>() }.frame());
|
||||
let mut behavior = native.collectionBehavior();
|
||||
if frame.is_some() {
|
||||
behavior.remove(B::FullScreenPrimary);
|
||||
behavior.insert(B::FullScreenAuxiliary | B::MoveToActiveSpace);
|
||||
} else {
|
||||
behavior.remove(B::FullScreenAuxiliary | B::MoveToActiveSpace);
|
||||
behavior.insert(B::FullScreenPrimary);
|
||||
}
|
||||
native.setCollectionBehavior(behavior);
|
||||
// Without a title bar the window has square corners and no frame, like the host.
|
||||
let mut style = native.styleMask();
|
||||
style.set(objc2_app_kit::NSWindowStyleMask::Titled, frame.is_none());
|
||||
native.setStyleMask(style);
|
||||
native.setMovable(frame.is_none());
|
||||
// Shown over the host it must appear at once, as a tab does, not zoom in like a new window.
|
||||
native.setAnimationBehavior(if frame.is_some() {
|
||||
objc2_app_kit::NSWindowAnimationBehavior::None
|
||||
} else {
|
||||
objc2_app_kit::NSWindowAnimationBehavior::Default
|
||||
});
|
||||
for kind in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] {
|
||||
if let Some(button) = native.standardWindowButton(kind) {
|
||||
button.setHidden(frame.is_some());
|
||||
fn native(window: &WebviewWindow) -> Option<&'static NSWindow> {
|
||||
let pointer = window.ns_window().ok()?;
|
||||
Some(unsafe { &*pointer.cast::<NSWindow>() })
|
||||
}
|
||||
|
||||
fn same_group(a: &NSWindow, b: &NSWindow) -> bool {
|
||||
match (a.tabGroup(), b.tabGroup()) {
|
||||
(Some(a), Some(b)) => Retained::as_ptr(&a) == Retained::as_ptr(&b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `members` in one native tab group and shows `selected`.
|
||||
pub fn sync_tab_group(members: Vec<WebviewWindow>, selected: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = selected.clone();
|
||||
on_main(selected, move || {
|
||||
let Some(shown) = native(&target) else { return };
|
||||
for member in members.iter().filter(|m| m.label() != target.label()) {
|
||||
if let Some(window) = native(member)
|
||||
&& !same_group(shown, window)
|
||||
{
|
||||
shown.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above);
|
||||
}
|
||||
}
|
||||
if let Some(frame) = frame {
|
||||
native.setFrame_display(frame, true);
|
||||
if let Some(group) = shown.tabGroup() {
|
||||
group.setSelectedWindow(Some(shown));
|
||||
}
|
||||
target.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts a group member shown in its host's fullscreen Space in front of the host, which AppKit
|
||||
/// otherwise keeps on top of the other windows in its Space.
|
||||
pub fn raise_over_host(window: &WebviewWindow, host: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = window.clone();
|
||||
let host = host.clone();
|
||||
on_main(window, move || {
|
||||
let (Ok(pointer), Ok(host)) = (target.ns_window(), host.ns_window()) else { return };
|
||||
let native = unsafe { &*pointer.cast::<NSWindow>() };
|
||||
let host = unsafe { &*host.cast::<NSWindow>() };
|
||||
native.orderWindow_relativeTo(objc2_app_kit::NSWindowOrderingMode::Above, host.windowNumber());
|
||||
})
|
||||
}
|
||||
|
||||
/// Watches the group's fullscreen `host` and runs `left` once it is back on the desktop.
|
||||
pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'static) -> tauri::Result<()> {
|
||||
let label = host.label().to_owned();
|
||||
let target = host.clone();
|
||||
on_main(host, move || unsafe {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = &*pointer.cast::<NSWindow>();
|
||||
let key = label.clone();
|
||||
let block = block2::RcBlock::new(move |_| {
|
||||
forget_fullscreen_host(&key);
|
||||
left();
|
||||
});
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWindowDidExitFullScreenNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer)));
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Windows being waited on to finish entering fullscreen.
|
||||
static ENTERING_FULLSCREEN: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`.
|
||||
pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let label = window.label().to_owned();
|
||||
let target = window.clone();
|
||||
on_main(window, move || unsafe {
|
||||
let Ok(pointer) = target.ns_window() else { return };
|
||||
let native = &*pointer.cast::<NSWindow>();
|
||||
let tx = std::cell::Cell::new(Some(tx));
|
||||
let block = block2::RcBlock::new(move |_| {
|
||||
if let Some(tx) = tx.take() {
|
||||
let _ = tx.send(());
|
||||
shown.makeKeyAndOrderFront(None);
|
||||
for member in &members {
|
||||
if let Some(window) = native(member) {
|
||||
hide_tab_bar(window);
|
||||
}
|
||||
});
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer)));
|
||||
})?;
|
||||
let toggled = native_toggle_fullscreen(window);
|
||||
if toggled.is_ok() {
|
||||
let _ = tokio::time::timeout(timeout, rx).await;
|
||||
}
|
||||
let label = window.label().to_owned();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes `window` out of its tab group into a window of its own; in full screen, a Space of its own.
|
||||
pub fn move_tab_to_new_window(window: &WebviewWindow) -> tauri::Result<()> {
|
||||
let target = window.clone();
|
||||
on_main(window, move || {
|
||||
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label));
|
||||
})?;
|
||||
toggled
|
||||
let Some(native) = native(&target) else { return };
|
||||
if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) {
|
||||
native.moveTabToNewWindow(None);
|
||||
}
|
||||
hide_tab_bar(native);
|
||||
})
|
||||
}
|
||||
|
||||
/// An observer on the workspace's notification center, removed when dropped.
|
||||
struct WorkspaceObserver(Retained<NSNotificationCenter>, Retained<ProtocolObject<dyn NSObjectProtocol>>);
|
||||
|
||||
impl Drop for WorkspaceObserver {
|
||||
fn drop(&mut self) {
|
||||
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.1;
|
||||
unsafe { self.0.removeObserver(observer.as_ref()) };
|
||||
}
|
||||
fn has_tab_bar(view: &objc2_app_kit::NSView) -> bool {
|
||||
let class = view.class().name().to_string_lossy();
|
||||
class.contains("TabBar") || view.subviews().iter().any(|v| has_tab_bar(&v))
|
||||
}
|
||||
|
||||
/// Work waiting for the active Space to change, with the observer that runs it.
|
||||
struct SpaceChange {
|
||||
_observer: WorkspaceObserver,
|
||||
then: Box<dyn FnOnce()>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SPACE_CHANGE: std::cell::RefCell<Option<SpaceChange>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `then` on the main thread once the active Space has changed, or after `timeout` when it
|
||||
/// changed before this was called. AppKit drops a fullscreen request made while a fullscreen
|
||||
/// Space is being torn down.
|
||||
pub fn after_space_change(app: &AppHandle, timeout: std::time::Duration, then: impl FnOnce() + Send + 'static) {
|
||||
fn run() {
|
||||
if let Some(pending) = SPACE_CHANGE.with_borrow_mut(Option::take) {
|
||||
(pending.then)();
|
||||
/// Hides AppKit's tab bar, which a window shows while its group has more than one tab. It moves to
|
||||
/// whichever tab is shown and comes back when the group changes, so this runs after each change.
|
||||
pub fn hide_tab_bar(window: &NSWindow) {
|
||||
for accessory in window.titlebarAccessoryViewControllers().iter() {
|
||||
if !accessory.isHidden() && has_tab_bar(&accessory.view()) {
|
||||
accessory.setHidden(true);
|
||||
}
|
||||
}
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
run();
|
||||
let block = block2::RcBlock::new(|_| run());
|
||||
let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter();
|
||||
let observer = unsafe {
|
||||
center.addObserverForName_object_queue_usingBlock(
|
||||
Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
)
|
||||
};
|
||||
let observer = WorkspaceObserver(center, observer);
|
||||
SPACE_CHANGE
|
||||
.with_borrow_mut(|pending| *pending = Some(SpaceChange { _observer: observer, then: Box::new(then) }));
|
||||
});
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(timeout).await;
|
||||
let _ = app.run_on_main_thread(run);
|
||||
});
|
||||
}
|
||||
|
||||
type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
|
||||
@@ -301,165 +171,4 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
|
||||
TERMINATE_CANCEL
|
||||
}
|
||||
|
||||
type ToggleFullScreen = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
|
||||
|
||||
static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock<ToggleFullScreen> = std::sync::OnceLock::new();
|
||||
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
|
||||
|
||||
/// The green window button, the Globe-F shortcut and the system's Enter Full Screen item all
|
||||
/// send `toggleFullScreen:`; a grouped window answers it for the whole group.
|
||||
pub fn intercept_toggle_fullscreen(window: &WebviewWindow) {
|
||||
let _ = APP.set(window.app_handle().clone());
|
||||
let _ = window.with_webview(|webview| unsafe {
|
||||
let class = objc2::ffi::object_getClass(webview.ns_window().cast()) as *mut AnyClass;
|
||||
let selector = objc2::sel!(toggleFullScreen:);
|
||||
if NATIVE_TOGGLE_FULLSCREEN.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(method) = (*class).instance_method(selector) else { return };
|
||||
let native: ToggleFullScreen = std::mem::transmute(method.implementation());
|
||||
let types = objc2::ffi::method_getTypeEncoding(method);
|
||||
let _ = NATIVE_TOGGLE_FULLSCREEN.set(native);
|
||||
let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
|
||||
let selector = objc2::sel!(constrainFrameRect:toScreen:);
|
||||
let Some(method) = (*class).instance_method(selector) else { return };
|
||||
let native: ConstrainFrameRect = std::mem::transmute(method.implementation());
|
||||
let types = objc2::ffi::method_getTypeEncoding(method);
|
||||
let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native);
|
||||
let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
});
|
||||
}
|
||||
|
||||
type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect;
|
||||
|
||||
static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock<ConstrainFrameRect> = std::sync::OnceLock::new();
|
||||
|
||||
thread_local! {
|
||||
/// Native window whose next frame assignment must keep the rectangle it was given.
|
||||
static EXACT_FRAME: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
struct ExactFrame(usize);
|
||||
|
||||
impl ExactFrame {
|
||||
fn begin(native: usize) -> Self {
|
||||
EXACT_FRAME.with(|slot| slot.set(native));
|
||||
Self(native)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExactFrame {
|
||||
fn drop(&mut self) {
|
||||
EXACT_FRAME.with(|slot| {
|
||||
if slot.get() == self.0 {
|
||||
slot.set(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns;
|
||||
/// tao's position and size setters only schedule the change.
|
||||
pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
|
||||
apply_window_frame(from, to, false)
|
||||
}
|
||||
|
||||
/// Copies the frame and orders `to` front before AppKit can animate or constrain it.
|
||||
/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place.
|
||||
pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
|
||||
apply_window_frame(from, to, true)
|
||||
}
|
||||
|
||||
fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
|
||||
if MainThreadMarker::new().is_some() {
|
||||
return apply_window_frame_on_main(from, to, show);
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
let from = from.clone();
|
||||
let to = to.clone();
|
||||
to.clone().run_on_main_thread(move || {
|
||||
let _ = tx.send(apply_window_frame_on_main(&from, &to, show));
|
||||
})?;
|
||||
rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))?
|
||||
}
|
||||
|
||||
fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
|
||||
let source_pointer = from.ns_window()?;
|
||||
let target_pointer = to.ns_window()?;
|
||||
unsafe {
|
||||
let source = &*source_pointer.cast::<NSWindow>();
|
||||
let target = &*target_pointer.cast::<NSWindow>();
|
||||
let frame = source.frame();
|
||||
let current = target.frame();
|
||||
let differs = frame.origin.x != current.origin.x
|
||||
|| frame.origin.y != current.origin.y
|
||||
|| frame.size.width != current.size.width
|
||||
|| frame.size.height != current.size.height;
|
||||
if !differs && !show {
|
||||
return Ok(());
|
||||
}
|
||||
// Zoom and ordering use the window's animation behavior. `animate: false` alone does not
|
||||
// stop a zoomed window from sliding to the new frame as it is shown.
|
||||
let behavior = target.animationBehavior();
|
||||
target.setAnimationBehavior(NSWindowAnimationBehavior::None);
|
||||
let result = (|| {
|
||||
let _exact = ExactFrame::begin(target_pointer as usize);
|
||||
if differs {
|
||||
target.setFrame_display_animate(frame, false, false);
|
||||
}
|
||||
if show {
|
||||
to.show()?;
|
||||
to.set_focus()?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
target.setAnimationBehavior(behavior);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the
|
||||
/// camera housing, whenever the window is placed, shown or resized. A group-tab frame copy keeps
|
||||
/// the rectangle it was given, so the window about to be shown is not shifted.
|
||||
unsafe extern "C-unwind" fn constrain_frame_rect(
|
||||
this: *mut AnyObject,
|
||||
selector: Sel,
|
||||
frame: NSRect,
|
||||
screen: *mut AnyObject,
|
||||
) -> NSRect {
|
||||
if EXACT_FRAME.with(|slot| slot.get() == this as usize) {
|
||||
return frame;
|
||||
}
|
||||
match NATIVE_CONSTRAIN_FRAME_RECT.get() {
|
||||
Some(native) => unsafe { native(this, selector, frame, screen) },
|
||||
None => frame,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) {
|
||||
if let Some(window) = APP.get().and_then(|app| grouped_window(app, this)) {
|
||||
if let Err(error) = crate::window_groups::toggle_fullscreen(&window) {
|
||||
tracing::warn!(message = error.message, "cannot toggle fullscreen");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(native) = NATIVE_TOGGLE_FULLSCREEN.get() {
|
||||
unsafe { native(this, selector, sender) };
|
||||
}
|
||||
}
|
||||
|
||||
fn project_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
|
||||
app.webview_windows()
|
||||
.into_values()
|
||||
.find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::<AnyObject>() == native))
|
||||
}
|
||||
|
||||
/// `native` as a project window in a window group.
|
||||
fn grouped_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
|
||||
let window = project_window(app, native)?;
|
||||
let grouped = app.state::<WindowsState>().groups.lock().group(window.label()).is_some();
|
||||
grouped.then_some(window)
|
||||
}
|
||||
+41
-34
@@ -191,6 +191,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("app.about", "About OpenIDEA"),
|
||||
act("app.checkForUpdates", "Check for Updates…"),
|
||||
SEP,
|
||||
act("app.installCli", "Install 'openidea' Command"),
|
||||
act("app.uninstallCli", "Uninstall 'openidea' Command"),
|
||||
SEP,
|
||||
act("file.settings", "Settings…"),
|
||||
SEP,
|
||||
Entry::Native(Native::Services),
|
||||
@@ -702,41 +705,45 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
],
|
||||
));
|
||||
|
||||
menus.push(sub(
|
||||
"tools",
|
||||
"Tools",
|
||||
vec![
|
||||
act("http.newRequest", "New HTTP Request"),
|
||||
act("http.showToolWindow", "HTTP Client"),
|
||||
let mut tools = vec![
|
||||
act("http.newRequest", "New HTTP Request"),
|
||||
act("http.showToolWindow", "HTTP Client"),
|
||||
SEP,
|
||||
act("db.showToolWindow", "Database"),
|
||||
act("db.newDataSource", "New Data Source…"),
|
||||
act("db.openConsole", "Open Query Console"),
|
||||
SEP,
|
||||
act("ssh.startSession", "Start SSH Session…"),
|
||||
act("ssh.configurations", "SSH Configurations…"),
|
||||
sub(
|
||||
"deployment",
|
||||
"Deployment",
|
||||
vec![
|
||||
act("deployment.uploadToDefault", "Upload to Default Server"),
|
||||
act("deployment.uploadTo", "Upload to…"),
|
||||
act("deployment.downloadFromDefault", "Download from Default Server"),
|
||||
act("deployment.downloadFrom", "Download from…"),
|
||||
act("deployment.syncWith", "Sync with Deployed…"),
|
||||
act("deployment.deleteFromServer", "Delete from Server"),
|
||||
SEP,
|
||||
act("deployment.toggleAutoUpload", "Automatic Upload"),
|
||||
act("deployment.browseRemoteHost", "Browse Remote Host"),
|
||||
act("deployment.openInBrowser", "Open in Browser"),
|
||||
act("deployment.configure", "Configuration…"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("vim.toggle", "Vim"),
|
||||
Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS),
|
||||
];
|
||||
if !mac {
|
||||
tools.extend([
|
||||
SEP,
|
||||
act("db.showToolWindow", "Database"),
|
||||
act("db.newDataSource", "New Data Source…"),
|
||||
act("db.openConsole", "Open Query Console"),
|
||||
SEP,
|
||||
act("ssh.startSession", "Start SSH Session…"),
|
||||
act("ssh.configurations", "SSH Configurations…"),
|
||||
sub(
|
||||
"deployment",
|
||||
"Deployment",
|
||||
vec![
|
||||
act("deployment.uploadToDefault", "Upload to Default Server"),
|
||||
act("deployment.uploadTo", "Upload to…"),
|
||||
act("deployment.downloadFromDefault", "Download from Default Server"),
|
||||
act("deployment.downloadFrom", "Download from…"),
|
||||
act("deployment.syncWith", "Sync with Deployed…"),
|
||||
act("deployment.deleteFromServer", "Delete from Server"),
|
||||
SEP,
|
||||
act("deployment.toggleAutoUpload", "Automatic Upload"),
|
||||
act("deployment.browseRemoteHost", "Browse Remote Host"),
|
||||
act("deployment.openInBrowser", "Open in Browser"),
|
||||
act("deployment.configure", "Configuration…"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("vim.toggle", "Vim"),
|
||||
Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS),
|
||||
],
|
||||
));
|
||||
act("app.installCli", "Install 'openidea' Command"),
|
||||
act("app.uninstallCli", "Uninstall 'openidea' Command"),
|
||||
]);
|
||||
}
|
||||
menus.push(sub("tools", "Tools", tools));
|
||||
|
||||
let mut window = Vec::new();
|
||||
if mac {
|
||||
|
||||
@@ -12,8 +12,9 @@ use objc2::{
|
||||
runtime::ProtocolObject,
|
||||
};
|
||||
use objc2_app_kit::{
|
||||
NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
|
||||
NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification,
|
||||
NSToolbar, NSWindow, NSWindowDidBecomeKeyNotification, NSWindowDidEnterFullScreenNotification,
|
||||
NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
|
||||
NSWindowWillEnterFullScreenNotification,
|
||||
};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
|
||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||
@@ -56,7 +57,7 @@ impl Drop for Observer {
|
||||
|
||||
struct Tracked {
|
||||
state: Rc<Fullscreen>,
|
||||
_observers: [Observer; 3],
|
||||
_observers: [Observer; 4],
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -85,9 +86,15 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
|
||||
change(&state);
|
||||
if let Some(window) = weak.load() {
|
||||
apply(&window, &state);
|
||||
crate::macos::hide_tab_bar(&window);
|
||||
}
|
||||
// The web title bar shows the window buttons itself in full screen.
|
||||
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
|
||||
// The web title bar releases the room kept for the window buttons in full screen.
|
||||
// Closing a window posts this while the runtime still holds its window table, and an
|
||||
// emit from here evaluates script in that table; send it from outside the notification.
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
|
||||
});
|
||||
});
|
||||
let observer = unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
@@ -104,6 +111,8 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
|
||||
observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter),
|
||||
observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter),
|
||||
observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit),
|
||||
// A tab shown in its group: AppKit's tab bar moves to it, and it may be in full screen.
|
||||
observe(NSWindowDidBecomeKeyNotification, |_| {}),
|
||||
]
|
||||
};
|
||||
Tracked { state, _observers: observers }
|
||||
|
||||
+54
-504
@@ -8,22 +8,13 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
|
||||
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
|
||||
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
|
||||
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
|
||||
/// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SPACE_TEARDOWN: Duration = Duration::from_millis(1500);
|
||||
/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SPACE_HANDOVER: Duration = Duration::from_millis(2000);
|
||||
/// Coalesces the move/resize events a drag emits.
|
||||
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod frames;
|
||||
|
||||
type Group = ide_workspace::window_groups::WindowGroup;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -62,410 +53,57 @@ fn changed(app: &AppHandle) {
|
||||
app.state::<AppState>().sink.emit(IdeEvent::WindowGroupsChanged);
|
||||
}
|
||||
|
||||
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
|
||||
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OuterFrame {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Where a group member can be placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameHost {
|
||||
/// The client can move and resize the window.
|
||||
Desktop,
|
||||
/// Wayland does not let clients place top-level windows.
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
Wayland,
|
||||
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
|
||||
NativeFullscreen,
|
||||
}
|
||||
|
||||
/// What to write before the target window is shown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameWrite {
|
||||
/// Already the same rectangle, or this host must not be placed.
|
||||
Leave,
|
||||
TransferFullscreen,
|
||||
Assign(OuterFrame),
|
||||
}
|
||||
|
||||
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
|
||||
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
|
||||
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
|
||||
match host {
|
||||
FrameHost::Wayland => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
|
||||
FrameHost::Desktop if from == to => FrameWrite::Leave,
|
||||
FrameHost::Desktop => FrameWrite::Assign(from),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
let position = window.outer_position().map_err(err)?;
|
||||
let size = window.outer_size().map_err(err)?;
|
||||
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
|
||||
}
|
||||
|
||||
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(FrameHost::Wayland);
|
||||
}
|
||||
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
|
||||
return Ok(FrameHost::NativeFullscreen);
|
||||
}
|
||||
Ok(FrameHost::Desktop)
|
||||
}
|
||||
|
||||
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
|
||||
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
|
||||
/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
// Wayland does not let clients place top-level windows; maximizing stays on that output.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(());
|
||||
}
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if let Some(monitor) = from.current_monitor().map_err(err)? {
|
||||
to.set_position(*monitor.position()).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives `to` the source window's outer frame before `to` is shown.
|
||||
///
|
||||
/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return
|
||||
/// before the frame changes, while `show` orders the window front immediately, so the old
|
||||
/// rectangle appears and then jumps. The frame is written synchronously instead.
|
||||
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let host = frame_host(from)?;
|
||||
if host == FrameHost::Wayland {
|
||||
return Ok(());
|
||||
}
|
||||
// Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is
|
||||
// the one `show` will present, so compare that instead of skipping a real difference.
|
||||
#[cfg(target_os = "macos")]
|
||||
if host == FrameHost::Desktop {
|
||||
return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()));
|
||||
}
|
||||
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
|
||||
FrameWrite::Leave => Ok(()),
|
||||
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
|
||||
FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
// A macOS group in fullscreen shares its host's Space, so no member copies the host's frame.
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (from, to, err);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
if from.is_maximized().map_err(err)? {
|
||||
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
|
||||
// alone are not enough; a hidden peer would otherwise appear at its old size.
|
||||
to.unmaximize().map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
to.maximize().map_err(err)?;
|
||||
} else {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
to.set_size(size).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `to` in place of `from`, at `from`'s frame.
|
||||
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
// Order the target front in the same AppKit turn as the frame write. A separate `show` runs
|
||||
// after that turn and constrains the titled window, which is the jump. Native fullscreen is
|
||||
// handed over by `copy_frame` instead.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !from.is_fullscreen().map_err(err)? {
|
||||
to.unminimize().map_err(err)?;
|
||||
return crate::macos::present_window_frame(from, to).map_err(err);
|
||||
}
|
||||
copy_frame(from, to)?;
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)
|
||||
}
|
||||
|
||||
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
|
||||
/// changes which window is visible instead of moving or resizing one on screen.
|
||||
fn sync_member_frames(app: &AppHandle, selected: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
|
||||
if group.selected != selected {
|
||||
return;
|
||||
}
|
||||
let Some(from) = app.get_webview_window(selected) else { return };
|
||||
if from.is_fullscreen().unwrap_or(false)
|
||||
|| from.is_minimized().unwrap_or(false)
|
||||
|| fullscreen_host(app, &group.members).is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
for member in group.members.iter().filter(|member| member.as_str() != selected) {
|
||||
let Some(to) = app.get_webview_window(member) else { continue };
|
||||
// A visible peer is the one being presented; never move it behind the user's back.
|
||||
if to.is_visible().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = copy_frame(&from, &to) {
|
||||
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
|
||||
/// the next tab selection is only a visibility change.
|
||||
/// A presented group window moved or resized. Native tabs (macOS) share one frame already.
|
||||
pub fn frame_changed(app: &AppHandle, label: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
|
||||
if group.selected != label {
|
||||
return;
|
||||
}
|
||||
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let app = app.clone();
|
||||
let label = label.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
|
||||
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
|
||||
return;
|
||||
}
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The member of `members` in native fullscreen. On macOS the other members of its group join
|
||||
/// its Space; elsewhere each member of a fullscreen group is fullscreen itself.
|
||||
fn fullscreen_host(app: &AppHandle, members: &[String]) -> Option<WebviewWindow> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false));
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app, members);
|
||||
None
|
||||
}
|
||||
frames::frame_changed(app, label);
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (app, label);
|
||||
}
|
||||
|
||||
/// The fullscreen host of `label`'s group, when that is another window.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn fullscreen_host_of(app: &AppHandle, label: &str) -> Option<WebviewWindow> {
|
||||
let members = app.state::<WindowsState>().groups.lock().group(label).map(|g| g.members.clone())?;
|
||||
fullscreen_host(app, &members).filter(|host| host.label() != label)
|
||||
}
|
||||
|
||||
/// Whether `window` is shown fullscreen: itself, or as a member of a fullscreen group.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn in_fullscreen(app: &AppHandle, window: &WebviewWindow) -> bool {
|
||||
if window.is_fullscreen().unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
let members = app.state::<WindowsState>().groups.lock().group(window.label()).map(|g| g.members.clone());
|
||||
members.is_some_and(|members| fullscreen_host(app, &members).is_some())
|
||||
}
|
||||
|
||||
/// Fullscreen for `window`. On macOS a group shares one fullscreen Space: the selected member
|
||||
/// enters native fullscreen and the others join its Space as auxiliary windows over it, so
|
||||
/// switching projects only shows one window and hides the others.
|
||||
/// Fullscreen for `window`. On macOS a group is one native tab group, which AppKit takes into a
|
||||
/// fullscreen Space as a whole.
|
||||
pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult<bool> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app = window.app_handle();
|
||||
let group = app.state::<WindowsState>().groups.lock().group(window.label()).cloned();
|
||||
if let Some(group) = group {
|
||||
if let Some(host) = fullscreen_host(app, &group.members) {
|
||||
// The host's watch brings the members back with it.
|
||||
crate::macos::native_toggle_fullscreen(&host).map_err(err)?;
|
||||
return Ok(false);
|
||||
}
|
||||
let host = app.get_webview_window(&group.selected).unwrap_or_else(|| window.clone());
|
||||
host_fullscreen(app, &host)?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let on = !window.is_fullscreen().map_err(err)?;
|
||||
window.set_fullscreen(on).map_err(err)?;
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
/// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
prepare_host(app, host)?;
|
||||
crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// `host` and the other members of its group, ready for `host` to enter fullscreen.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let members = app.state::<WindowsState>().groups.lock().group(host.label()).map(|g| g.members.clone());
|
||||
crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?;
|
||||
for member in members.iter().flatten().filter(|m| *m != host.label()) {
|
||||
if let Some(other) = app.get_webview_window(member) {
|
||||
crate::macos::set_fullscreen_auxiliary(&other, Some(host)).map_err(err)?;
|
||||
}
|
||||
}
|
||||
if members.is_some() {
|
||||
watch_host(app, host)?;
|
||||
}
|
||||
host.show().map_err(err)
|
||||
}
|
||||
|
||||
/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves
|
||||
/// for a Space of its own first, so the screen moves straight to it; were the host closed first,
|
||||
/// its Space would end and show the desktop until the next one opened.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) };
|
||||
if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) {
|
||||
return Ok(());
|
||||
}
|
||||
// Detached now, the closing host leaves nothing for `destroyed` to hand over.
|
||||
let next = state.groups.lock().detach(caller);
|
||||
changed(app);
|
||||
let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) };
|
||||
prepare_host(app, &next)?;
|
||||
crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
windows::focus(&next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn watch_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
|
||||
let app = app.clone();
|
||||
let label = host.label().to_owned();
|
||||
crate::macos::watch_fullscreen_host(host, move || left_fullscreen(&app, &label))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// The group's fullscreen host is back on the desktop: the members leave its Space with it at
|
||||
/// its frame, and the selected one is shown.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn left_fullscreen(app: &AppHandle, host: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(host).cloned() else { return };
|
||||
let Some(host) = app.get_webview_window(host) else { return };
|
||||
for member in group.members.iter().filter(|m| *m != host.label()) {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
let _ = crate::macos::set_fullscreen_auxiliary(&window, None);
|
||||
let _ = copy_frame(&host, &window);
|
||||
}
|
||||
}
|
||||
let _ = select(app, &group.selected, &group.selected);
|
||||
}
|
||||
|
||||
pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
|
||||
let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?;
|
||||
let state = app.state::<WindowsState>();
|
||||
let previous = state.groups.lock().select(caller, target)?;
|
||||
// In a fullscreen group the target joins the host's Space, covering its screen.
|
||||
let host = fullscreen_host(app, &previous.members);
|
||||
let result = (|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = &host {
|
||||
// Also a window that was fullscreen alone before it was grouped.
|
||||
watch_host(app, host)?;
|
||||
if host.label() != target {
|
||||
crate::macos::set_fullscreen_auxiliary(&to, Some(host))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
if host.is_none()
|
||||
&& previous.selected != target
|
||||
&& let Some(from) = app.get_webview_window(&previous.selected)
|
||||
{
|
||||
show_at_frame(&from, &to)?;
|
||||
} else {
|
||||
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
to.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = host.as_ref().filter(|h| h.label() != target) {
|
||||
crate::macos::raise_over_host(&to, host).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
for member in &previous.members {
|
||||
// The host stays in its Space behind the selected member; hiding it would end the Space.
|
||||
if member != target
|
||||
&& host.as_ref().is_none_or(|h| h.label() != member)
|
||||
&& let Some(window) = app.get_webview_window(member)
|
||||
{
|
||||
window.hide().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = native_select(app, &previous, &to);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let result = frames::present(app, &previous, target, &to);
|
||||
if result.is_err() {
|
||||
let _ = state.groups.lock().select(caller, &previous.selected);
|
||||
for member in &previous.members {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
if member == &previous.selected {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = app.get_webview_window(&previous.selected) {
|
||||
let _ = native_select(app, &previous, &window);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
frames::restore_selection(app, &previous);
|
||||
} else if !state.quitting() {
|
||||
// Align the members that are now hidden while they stay invisible, so the next
|
||||
// selection does not move or resize a window on screen. Hiding is asynchronous;
|
||||
// wait for it before moving a peer that is still on screen.
|
||||
let app = app.clone();
|
||||
let target = target.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &target);
|
||||
}
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
frames::selected(app, target);
|
||||
}
|
||||
changed(app);
|
||||
// A tab shown in a fullscreen group is in full screen too; its title bar asks again.
|
||||
app.state::<AppState>().sink.emit(IdeEvent::WindowFullscreenChanged);
|
||||
result
|
||||
}
|
||||
|
||||
/// Shows `to` as the selected tab of the group's native tab group.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn native_select(app: &AppHandle, group: &Group, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let members = group.members.iter().filter_map(|m| app.get_webview_window(m)).collect();
|
||||
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
crate::macos::sync_tab_group(members, to).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
@@ -492,88 +130,43 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let state = app.state::<WindowsState>();
|
||||
#[cfg(target_os = "macos")]
|
||||
let host = {
|
||||
let members = state.groups.lock().group(caller).map(|g| g.members.clone()).unwrap_or_default();
|
||||
fullscreen_host(app, &members)
|
||||
};
|
||||
let next = state.groups.lock().detach(caller);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(host) = host {
|
||||
detach_fullscreen(app, caller, &host, next.as_deref())?;
|
||||
let Some(next) = state.groups.lock().detach(caller) else {
|
||||
changed(app);
|
||||
return Ok(());
|
||||
};
|
||||
let window = app.get_webview_window(caller);
|
||||
// A tab moved out of a fullscreen group stays fullscreen in a Space of its own.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = &window {
|
||||
crate::macos::move_tab_to_new_window(window).map_err(err)?;
|
||||
}
|
||||
if let Some(next) = next {
|
||||
if let Some(window) = app.get_webview_window(&next) {
|
||||
window.show().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if let Some(next) = app.get_webview_window(&next) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if state.groups.lock().group(next.label()).is_some() {
|
||||
select(app, next.label(), next.label())?;
|
||||
}
|
||||
if let Some(window) = app.get_webview_window(caller) {
|
||||
if let Ok(position) = window.outer_position() {
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
|
||||
}
|
||||
windows::focus(&window);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
next.show().map_err(err)?;
|
||||
}
|
||||
if let Some(window) = window {
|
||||
if !window.is_fullscreen().map_err(err)?
|
||||
&& let Ok(position) = window.outer_position()
|
||||
{
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
|
||||
}
|
||||
windows::focus(&window);
|
||||
}
|
||||
changed(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tab moved out of a fullscreen group stays fullscreen in a Space of its own, as a macOS
|
||||
/// tab moved to a new window does; the tabs left keep theirs.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detach_fullscreen(app: &AppHandle, caller: &str, host: &WebviewWindow, next: Option<&str>) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
let next = next.and_then(|label| app.get_webview_window(label));
|
||||
let grouped = |label: &str| app.state::<WindowsState>().groups.lock().group(label).is_some();
|
||||
if host.label() == caller {
|
||||
// The host keeps its Space; the tabs left need a host of their own.
|
||||
crate::macos::forget_fullscreen_host(caller);
|
||||
if let Some(next) = &next {
|
||||
host_fullscreen(app, next)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(next) = &next {
|
||||
if grouped(next.label()) {
|
||||
select(app, next.label(), next.label())?;
|
||||
} else {
|
||||
// The group dissolved; its last window is fullscreen alone.
|
||||
crate::macos::forget_fullscreen_host(next.label());
|
||||
crate::macos::set_fullscreen_auxiliary(next, None).map_err(err)?;
|
||||
}
|
||||
}
|
||||
if let Some(window) = app.get_webview_window(caller) {
|
||||
host_fullscreen(app, &window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn destroyed(app: &AppHandle, caller: &str) {
|
||||
let Some(state) = app.try_state::<WindowsState>() else { return };
|
||||
#[cfg(target_os = "macos")]
|
||||
let hosted = crate::macos::forget_fullscreen_host(caller);
|
||||
let next = state.groups.lock().detach(caller);
|
||||
if !state.quitting() {
|
||||
if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) {
|
||||
// The Space ended with its host; the tabs left take a new one once it is gone.
|
||||
#[cfg(target_os = "macos")]
|
||||
if hosted {
|
||||
let host_app = app.clone();
|
||||
crate::macos::after_space_change(app, SPACE_TEARDOWN, move || {
|
||||
if let Err(error) = host_fullscreen(&host_app, &window) {
|
||||
tracing::warn!("cannot keep window group fullscreen: {}", error.message);
|
||||
}
|
||||
windows::focus(&window);
|
||||
});
|
||||
} else {
|
||||
windows::focus(&window);
|
||||
if let Some(host) = fullscreen_host_of(app, window.label()) {
|
||||
let _ = crate::macos::raise_over_host(&window, &host);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
windows::focus(&window);
|
||||
}
|
||||
let app = app.clone();
|
||||
@@ -621,6 +214,7 @@ pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec<String>
|
||||
/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open.
|
||||
pub fn cancel_close(app: &AppHandle, caller: &str) {
|
||||
let state = app.state::<WindowsState>();
|
||||
state.close_settled(caller);
|
||||
let mut batch = state.tab_closes.lock();
|
||||
if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) {
|
||||
*batch = None;
|
||||
@@ -773,47 +367,3 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
|
||||
|
||||
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
|
||||
OuterFrame { x, y, width, height }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_outer_rects_are_left_alone() {
|
||||
let frame = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
|
||||
let from = frame(100, 80, 1200, 800);
|
||||
let to = frame(40, 40, 900, 600);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_windows_with_different_rects_still_assign() {
|
||||
let from = frame(0, 25, 1728, 1055);
|
||||
let to = frame(0, 25, 1512, 900);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_does_not_place_a_window() {
|
||||
let from = frame(0, 0, 1200, 800);
|
||||
let to = frame(10, 10, 800, 600);
|
||||
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fullscreen_transfers_only_when_the_rects_differ() {
|
||||
let from = frame(0, 0, 1728, 1117);
|
||||
let other = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Window groups without native tabs: one member is shown at a time, and the hidden ones are kept
|
||||
//! at its frame so that selecting a tab only changes which window is visible.
|
||||
use super::Group;
|
||||
use crate::windows::WindowsState;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
|
||||
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
|
||||
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
|
||||
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
|
||||
/// Coalesces the move/resize events a drag emits.
|
||||
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
|
||||
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OuterFrame {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Where a group member can be placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameHost {
|
||||
/// The client can move and resize the window.
|
||||
Desktop,
|
||||
/// Wayland does not let clients place top-level windows.
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
Wayland,
|
||||
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
|
||||
NativeFullscreen,
|
||||
}
|
||||
|
||||
/// What to write before the target window is shown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FrameWrite {
|
||||
/// Already the same rectangle, or this host must not be placed.
|
||||
Leave,
|
||||
TransferFullscreen,
|
||||
Assign(OuterFrame),
|
||||
}
|
||||
|
||||
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
|
||||
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
|
||||
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
|
||||
match host {
|
||||
FrameHost::Wayland => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
|
||||
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
|
||||
FrameHost::Desktop if from == to => FrameWrite::Leave,
|
||||
FrameHost::Desktop => FrameWrite::Assign(from),
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
let position = window.outer_position().map_err(err)?;
|
||||
let size = window.outer_size().map_err(err)?;
|
||||
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
|
||||
}
|
||||
|
||||
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(FrameHost::Wayland);
|
||||
}
|
||||
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
|
||||
return Ok(FrameHost::NativeFullscreen);
|
||||
}
|
||||
Ok(FrameHost::Desktop)
|
||||
}
|
||||
|
||||
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
|
||||
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
|
||||
/// visibly jump across displays when its tab is selected.
|
||||
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
// Wayland does not let clients place top-level windows; maximizing stays on that output.
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::linux_window_state::is_wayland() {
|
||||
return Ok(());
|
||||
}
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if let Some(monitor) = from.current_monitor().map_err(err)? {
|
||||
to.set_position(*monitor.position()).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives `to` the source window's outer frame before `to` is shown.
|
||||
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let host = frame_host(from)?;
|
||||
if host == FrameHost::Wayland {
|
||||
return Ok(());
|
||||
}
|
||||
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
|
||||
FrameWrite::Leave => Ok(()),
|
||||
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
|
||||
FrameWrite::Assign(_) => assign_outer_frame(from, to),
|
||||
}
|
||||
}
|
||||
|
||||
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
from.set_fullscreen(false).map_err(err)?;
|
||||
to.set_fullscreen(true).map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
to.set_fullscreen(false).map_err(err)?;
|
||||
if from.is_maximized().map_err(err)? {
|
||||
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
|
||||
// alone are not enough; a hidden peer would otherwise appear at its old size.
|
||||
to.unmaximize().map_err(err)?;
|
||||
place_on_monitor(from, to)?;
|
||||
to.maximize().map_err(err)?;
|
||||
} else {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
to.set_size(size).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows `to` in place of `from`, at `from`'s frame.
|
||||
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |error: tauri::Error| IdeError::internal(error.to_string());
|
||||
copy_frame(from, to)?;
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)
|
||||
}
|
||||
|
||||
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
|
||||
/// changes which window is visible instead of moving or resizing one on screen.
|
||||
fn sync_member_frames(app: &AppHandle, selected: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
|
||||
if group.selected != selected {
|
||||
return;
|
||||
}
|
||||
let Some(from) = app.get_webview_window(selected) else { return };
|
||||
if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
for member in group.members.iter().filter(|member| member.as_str() != selected) {
|
||||
let Some(to) = app.get_webview_window(member) else { continue };
|
||||
// A visible peer is the one being presented; never move it behind the user's back.
|
||||
if to.is_visible().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = copy_frame(&from, &to) {
|
||||
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
|
||||
/// the next tab selection is only a visibility change.
|
||||
pub(super) fn frame_changed(app: &AppHandle, label: &str) {
|
||||
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
|
||||
if group.selected != label {
|
||||
return;
|
||||
}
|
||||
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let app = app.clone();
|
||||
let label = label.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
|
||||
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
|
||||
return;
|
||||
}
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows `to` in place of the group's previously selected member and hides the others.
|
||||
pub(super) fn present(app: &AppHandle, previous: &Group, target: &str, to: &WebviewWindow) -> IdeResult<()> {
|
||||
let err = |e: tauri::Error| IdeError::internal(e.to_string());
|
||||
if previous.selected != target
|
||||
&& let Some(from) = app.get_webview_window(&previous.selected)
|
||||
{
|
||||
show_at_frame(&from, to)?;
|
||||
} else {
|
||||
to.unminimize().map_err(err)?;
|
||||
to.show().map_err(err)?;
|
||||
to.set_focus().map_err(err)?;
|
||||
}
|
||||
for member in &previous.members {
|
||||
if member != target
|
||||
&& let Some(window) = app.get_webview_window(member)
|
||||
{
|
||||
window.hide().map_err(err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the previously selected member again after a failed selection.
|
||||
pub(super) fn restore_selection(app: &AppHandle, previous: &Group) {
|
||||
for member in &previous.members {
|
||||
if let Some(window) = app.get_webview_window(member) {
|
||||
if member == &previous.selected {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligns the members that are now hidden while they stay invisible, so the next selection does
|
||||
/// not move or resize a window on screen. Hiding is asynchronous; wait for it before moving a
|
||||
/// peer that is still on screen.
|
||||
pub(super) fn selected(app: &AppHandle, target: &str) {
|
||||
let app = app.clone();
|
||||
let target = target.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
|
||||
if !app.state::<WindowsState>().quitting() {
|
||||
sync_member_frames(&app, &target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
|
||||
|
||||
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
|
||||
OuterFrame { x, y, width, height }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_outer_rects_are_left_alone() {
|
||||
let frame = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
|
||||
let from = frame(100, 80, 1200, 800);
|
||||
let to = frame(40, 40, 900, 600);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_windows_with_different_rects_still_assign() {
|
||||
let from = frame(0, 25, 1728, 1055);
|
||||
let to = frame(0, 25, 1512, 900);
|
||||
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_does_not_place_a_window() {
|
||||
let from = frame(0, 0, 1200, 800);
|
||||
let to = frame(10, 10, 800, 600);
|
||||
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_fullscreen_transfers_only_when_the_rects_differ() {
|
||||
let from = frame(0, 0, 1728, 1117);
|
||||
let other = frame(100, 80, 1200, 800);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
|
||||
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
|
||||
}
|
||||
}
|
||||
+57
-10
@@ -140,6 +140,9 @@ pub struct WindowsState {
|
||||
last_focused: Mutex<Option<String>>,
|
||||
/// Set by Quit while windows close one by one.
|
||||
quitting: AtomicBool,
|
||||
/// Windows asked to close whose guards have not finished. AppKit closes a whole tab group at
|
||||
/// once, so its members ask together.
|
||||
close_requests: Mutex<Vec<String>>,
|
||||
/// Project tabs closing one by one (Close Other Tabs and friends).
|
||||
pub tab_closes: Mutex<Option<crate::window_groups::TabCloses>>,
|
||||
/// Relaunch once the quit completes (after installing an update).
|
||||
@@ -149,6 +152,21 @@ pub struct WindowsState {
|
||||
}
|
||||
|
||||
impl WindowsState {
|
||||
pub fn close_requested(&self, label: &str) {
|
||||
let mut requests = self.close_requests.lock();
|
||||
if !requests.iter().any(|l| l == label) {
|
||||
requests.push(label.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_settled(&self, label: &str) {
|
||||
self.close_requests.lock().retain(|l| l != label);
|
||||
}
|
||||
|
||||
pub fn pending_closes(&self) -> Vec<String> {
|
||||
self.close_requests.lock().clone()
|
||||
}
|
||||
|
||||
pub fn set_launch(&self, label: &str, launch: Launch) {
|
||||
self.launches.lock().insert(label.to_owned(), launch);
|
||||
}
|
||||
@@ -187,6 +205,7 @@ impl WindowsState {
|
||||
}
|
||||
|
||||
pub fn forget(&self, label: &str) {
|
||||
self.close_settled(label);
|
||||
self.launches.lock().remove(label);
|
||||
let mut last = self.last_focused.lock();
|
||||
if last.as_deref() == Some(label) {
|
||||
@@ -286,6 +305,9 @@ pub fn settings_label(parent: &str) -> String {
|
||||
format!("{}-{parent}", AuxKind::Settings.name())
|
||||
}
|
||||
|
||||
/// Held from choosing a project window's label until the window exists.
|
||||
static CREATING: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Windows being built to join a window group, which takes their frame from the group.
|
||||
#[cfg(target_os = "macos")]
|
||||
static JOINING_GROUP: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
@@ -665,6 +687,8 @@ fn create_window_for(
|
||||
) -> IdeResult<WebviewWindow> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? };
|
||||
// The label is free only until the window exists, so concurrent opens take turns.
|
||||
let creating = CREATING.lock();
|
||||
let windows = app.webview_windows();
|
||||
let label = next_label(windows.keys().map(String::as_str));
|
||||
let mut config = window_config(app)?;
|
||||
@@ -691,6 +715,7 @@ fn create_window_for(
|
||||
config.visible = false;
|
||||
}
|
||||
let window = build_project_window(app, config);
|
||||
drop(creating);
|
||||
// Plugins hear of the window from the main thread's queue; forget it after them.
|
||||
#[cfg(target_os = "macos")]
|
||||
if tab_target.is_some() {
|
||||
@@ -699,8 +724,6 @@ fn create_window_for(
|
||||
}
|
||||
let window = window?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos::intercept_toggle_fullscreen(&window);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(target) = tab_target {
|
||||
crate::window_groups::join(app, target.label(), window.label())?;
|
||||
return Ok(window);
|
||||
@@ -1129,19 +1152,43 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
|
||||
remember_cli_position(&app.state::<WindowsState>(), &args, &path);
|
||||
open_path(app, &path)
|
||||
}
|
||||
None => {
|
||||
if let Some(w) = target_window(app) {
|
||||
focus(&w);
|
||||
} else {
|
||||
spawn_window(app, None);
|
||||
}
|
||||
}
|
||||
None => focus_any(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Focuses the window the user last looked at, else opens a new one.
|
||||
fn focus_any(app: &AppHandle) {
|
||||
if let Some(w) = target_window(app) {
|
||||
focus(&w);
|
||||
} else {
|
||||
spawn_window(app, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens what the `openidea` command asked for, like [`open_paths`]; files open at their
|
||||
/// 1-based line and column. Without targets, focuses a window.
|
||||
pub fn open_targets(app: &AppHandle, targets: Vec<ide_cli::Target>) {
|
||||
if targets.is_empty() {
|
||||
return focus_any(app);
|
||||
}
|
||||
let windows = app.state::<WindowsState>();
|
||||
let paths = targets
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
if let Some(line) = t.line
|
||||
&& !t.path.is_dir()
|
||||
{
|
||||
windows.set_position(&t.path, (line.saturating_sub(1), t.column.map_or(0, |c| c.saturating_sub(1))));
|
||||
}
|
||||
t.path
|
||||
})
|
||||
.collect();
|
||||
open_paths(app, paths);
|
||||
}
|
||||
|
||||
/// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is
|
||||
/// looking at (a project window, never an auxiliary one), else opens a new window for it.
|
||||
fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) {
|
||||
pub(crate) fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) {
|
||||
let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() };
|
||||
let target = target_window(app)
|
||||
.filter(|window| auxiliary_parent(window.label()).is_none())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.38",
|
||||
"version": "0.1.39",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"files": {
|
||||
"Resources/bin/openidea": "./cli/openidea"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"name": "Text and Source Code",
|
||||
|
||||
+10
-9
@@ -42,11 +42,6 @@ export const commands = {
|
||||
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
|
||||
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
|
||||
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
|
||||
/**
|
||||
* This window closes next (its prompts passed): a fullscreen group's host first hands its Space
|
||||
* to the tab that takes over.
|
||||
*/
|
||||
windowGroupBeforeClose: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_before_close")),
|
||||
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
|
||||
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
|
||||
/**
|
||||
@@ -925,10 +920,7 @@ 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.
|
||||
*/
|
||||
/** Whether this window is in native full screen (on macOS, also a tab of a fullscreen group). */
|
||||
windowIsFullscreen: () => __TAURI_INVOKE<boolean>("window_is_fullscreen"),
|
||||
/**
|
||||
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
@@ -987,6 +979,10 @@ export const commands = {
|
||||
*/
|
||||
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
|
||||
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
|
||||
/** Installs the `openidea` command for this user. */
|
||||
appCliInstall: () => typedError<CliInstallation, IdeError>(__TAURI_INVOKE("app_cli_install")),
|
||||
/** Removes the installed `openidea` command; returns the links removed. */
|
||||
appCliUninstall: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("app_cli_uninstall")),
|
||||
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
|
||||
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
|
||||
/**
|
||||
@@ -2292,6 +2288,11 @@ export type ClearCachesResult = {
|
||||
rssAfterBytes: number | null,
|
||||
};
|
||||
|
||||
export type CliInstallation = {
|
||||
path: IpcPath,
|
||||
pathDirectory: IpcPath | null,
|
||||
};
|
||||
|
||||
export type CloseContext = {
|
||||
/** Quit is in progress (exit was already confirmed). */
|
||||
quitting: boolean,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Editor actions (ids from keymaps/intellij-macos.ts). */
|
||||
import { EditorSelection, type EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { indentSelection, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
|
||||
import { indentSelection, moveLineDown, moveLineUp } from "@codemirror/commands";
|
||||
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
|
||||
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
|
||||
import { executeAction, registerActions, type Action } from "@/platform/actions";
|
||||
@@ -9,6 +9,7 @@ import { getContext } from "@/platform/context";
|
||||
import { editorController, useGotoLine, useTabs } from "./controller";
|
||||
import {
|
||||
addCaretsToLineEnds,
|
||||
commentBlock,
|
||||
commentLine,
|
||||
completeStatement,
|
||||
deleteLine,
|
||||
@@ -661,7 +662,7 @@ export function registerEditorActions(): () => void {
|
||||
"color scheme",
|
||||
]),
|
||||
text("code.commentLine", "Comment with Line Comment", (v) => commentLine(v)),
|
||||
text("code.commentBlock", "Comment with Block Comment", (v) => toggleBlockComment(v)),
|
||||
text("code.commentBlock", "Comment with Block Comment", (v) => commentBlock(v)),
|
||||
text("code.duplicateLine", "Duplicate Line or Selection", (v) => duplicateLineOrSelection(v)),
|
||||
text("code.deleteLine", "Delete Line", (v) => deleteLine(v)),
|
||||
text("code.moveLineUp", "Move Line Up", (v) => moveLineUp(v)),
|
||||
@@ -778,6 +779,14 @@ export function registerEditorActions(): () => void {
|
||||
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
|
||||
keywords: ["reindent", "fix indentation"],
|
||||
}),
|
||||
{
|
||||
id: "editor.addOrRemoveCaret",
|
||||
title: "Add or Remove Caret",
|
||||
category: "Editor Actions",
|
||||
when: "textEditorActive",
|
||||
// The mouse selection style handles the pointer position and dragging.
|
||||
run: () => {},
|
||||
},
|
||||
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }),
|
||||
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Comment with Line Comment and Enter inside a line comment, as in IDEA.
|
||||
* Comment with Line Comment, Comment with Block Comment and Enter inside a
|
||||
* line comment, as in IDEA.
|
||||
*/
|
||||
import {
|
||||
EditorSelection,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from "@codemirror/state";
|
||||
import type { Command, EditorView } from "@codemirror/view";
|
||||
import { indentString, syntaxTree } from "@codemirror/language";
|
||||
import { toggleComment } from "@codemirror/commands";
|
||||
import { toggleBlockComment, toggleComment } from "@codemirror/commands";
|
||||
import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn";
|
||||
|
||||
/** Code Style › <Language> › Code Generation › Comment Code. */
|
||||
@@ -76,7 +77,7 @@ export const commentLine: Command = (view) => {
|
||||
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
|
||||
);
|
||||
// Languages without line comments (CSS, HTML…): block comments.
|
||||
if (tokens.some((t) => !t)) return toggleComment(view);
|
||||
if (tokens.some((t) => !t)) return blockCommentLines(view);
|
||||
|
||||
let allCommented = true;
|
||||
let commentedNonEmpty = false;
|
||||
@@ -195,6 +196,164 @@ function minIndent(state: EditorState, startLine: number, endLine: number, token
|
||||
return min ?? 0;
|
||||
}
|
||||
|
||||
// --- Block comments ---------------------------------------------------------------
|
||||
|
||||
interface CommentPair {
|
||||
open: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
/** The block comment token pairs the language offers at `pos`, the more specific first. */
|
||||
function commentPairs(state: EditorState, pos: number): CommentPair[] {
|
||||
const pairs: CommentPair[] = [];
|
||||
for (const data of state.languageDataAt<{ block?: CommentPair }>("commentTokens", pos)) {
|
||||
const block = data?.block;
|
||||
if (!block?.open || !block.close) continue;
|
||||
if (!pairs.some((p) => p.open === block.open && p.close === block.close)) pairs.push(block);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
const BLOCK_SEARCH = 50;
|
||||
|
||||
/** A block comment around a range: the tokens' own positions, and how much of their margin to take. */
|
||||
interface BlockComment {
|
||||
open: number;
|
||||
openMargin: number;
|
||||
close: number;
|
||||
closeMargin: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment `pair` around `from..to`: the tokens just outside the range, or
|
||||
* just inside it when the range takes the tokens in itself.
|
||||
*/
|
||||
function findBlockComment(state: EditorState, pair: CommentPair, from: number, to: number): BlockComment | null {
|
||||
const doc = state.doc;
|
||||
const before = state.sliceDoc(Math.max(0, from - BLOCK_SEARCH), from);
|
||||
const after = state.sliceDoc(to, Math.min(doc.length, to + BLOCK_SEARCH));
|
||||
const spaceBefore = /\s*$/.exec(before)![0].length;
|
||||
const spaceAfter = /^\s*/.exec(after)![0].length;
|
||||
const start = before.length - spaceBefore;
|
||||
if (
|
||||
before.slice(start - pair.open.length, start) === pair.open &&
|
||||
after.slice(spaceAfter, spaceAfter + pair.close.length) === pair.close
|
||||
)
|
||||
return {
|
||||
open: from - spaceBefore,
|
||||
openMargin: spaceBefore ? 1 : 0,
|
||||
close: to + spaceAfter,
|
||||
closeMargin: spaceAfter ? 1 : 0,
|
||||
};
|
||||
const inside = to - from <= 2 * BLOCK_SEARCH;
|
||||
const head = state.sliceDoc(from, inside ? to : from + BLOCK_SEARCH);
|
||||
const tail = inside ? head : state.sliceDoc(Math.max(from, to - BLOCK_SEARCH), to);
|
||||
const headSpace = /^\s*/.exec(head)![0].length;
|
||||
const tailSpace = /\s*$/.exec(tail)![0].length;
|
||||
const end = tail.length - tailSpace - pair.close.length;
|
||||
if (
|
||||
head.slice(headSpace, headSpace + pair.open.length) === pair.open &&
|
||||
tail.slice(end, end + pair.close.length) === pair.close
|
||||
)
|
||||
return {
|
||||
open: from + headSpace + pair.open.length,
|
||||
openMargin: /\s/.test(head.charAt(headSpace + pair.open.length)) ? 1 : 0,
|
||||
close: to - tailSpace - pair.close.length,
|
||||
closeMargin: /\s/.test(tail.charAt(end - 1)) ? 1 : 0,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The lines the selection touches, from their first non-blank column to their end. */
|
||||
function selectedLines(state: EditorState): { from: number; to: number }[] {
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
const doc = state.doc;
|
||||
for (const r of state.selection.ranges) {
|
||||
const firstLine = doc.lineAt(r.from);
|
||||
let lastLine = r.to <= firstLine.to ? firstLine : doc.lineAt(r.to);
|
||||
if (lastLine.from > firstLine.from && lastLine.from === r.to)
|
||||
lastLine = r.to === firstLine.to + 1 ? firstLine : doc.lineAt(r.to - 1);
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && last.to > firstLine.from) last.to = lastLine.to;
|
||||
else ranges.push({ from: firstLine.from + leadingWs(firstLine.text), to: lastLine.to });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the block comment wrapping every one of `ranges`; false when one of
|
||||
* them is not commented (the caller then comments instead).
|
||||
*
|
||||
* The pair to look for is read at both ends of a range and past its last line,
|
||||
* and just outside matching tokens. A comment can span syntax contexts:
|
||||
* a JSX `{/* … *\/}` is the extreme case — it sits in JSX children,
|
||||
* while its own text parses as JavaScript, where the tokens are `/* … *\/`. The
|
||||
* widest pair wins when several fit, so the outer comment is the one removed.
|
||||
*/
|
||||
function uncommentBlock(view: EditorView, ranges: { from: number; to: number }[]): boolean {
|
||||
const { state } = view;
|
||||
const doc = state.doc;
|
||||
const comments = ranges.map((r) => {
|
||||
const pairs = commentPairs(state, r.from);
|
||||
for (const pos of [r.to, doc.lineAt(r.to).to])
|
||||
for (const pair of commentPairs(state, pos))
|
||||
if (!pairs.some((p) => p.open === pair.open && p.close === pair.close)) pairs.push(pair);
|
||||
let best: { pair: CommentPair; comment: BlockComment } | null = null;
|
||||
for (const pair of pairs) {
|
||||
const comment = findBlockComment(state, pair, r.from, r.to);
|
||||
if (comment)
|
||||
for (const pos of [
|
||||
Math.max(0, comment.open - pair.open.length - 1),
|
||||
Math.min(doc.length, comment.close + pair.close.length + 1),
|
||||
])
|
||||
for (const outer of commentPairs(state, pos))
|
||||
if (!pairs.some((p) => p.open === outer.open && p.close === outer.close)) pairs.push(outer);
|
||||
if (comment && (!best || comment.open - pair.open.length < best.comment.open - best.pair.open.length))
|
||||
best = { pair, comment };
|
||||
}
|
||||
return best;
|
||||
});
|
||||
// End tokens from separate comments must not be removed across live code.
|
||||
for (const done of comments) {
|
||||
if (!done) continue;
|
||||
const { pair, comment } = done;
|
||||
if (!state.sliceDoc(comment.open, comment.close).includes(pair.close)) continue;
|
||||
let node = syntaxTree(state).resolveInner(comment.open, 1);
|
||||
while (node.parent && !/comment/i.test(node.name)) node = node.parent;
|
||||
// A parser-confirmed nested comment can legitimately contain closing tokens.
|
||||
if (
|
||||
!/comment/i.test(node.name) ||
|
||||
node.from !== comment.open - pair.open.length ||
|
||||
node.to !== comment.close + pair.close.length
|
||||
)
|
||||
return true;
|
||||
}
|
||||
if (comments.some((c) => !c)) return false;
|
||||
|
||||
const changes: ChangeSpec[] = [];
|
||||
for (const done of comments) {
|
||||
const { pair, comment } = done!;
|
||||
changes.push(
|
||||
{ from: comment.open - pair.open.length, to: comment.open + comment.openMargin },
|
||||
{ from: comment.close - comment.closeMargin, to: comment.close + pair.close.length },
|
||||
);
|
||||
}
|
||||
view.dispatch({ changes, scrollIntoView: true, userEvent: "delete.uncomment" });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ⌘⇧/: block comments the selection, or takes the block comment back out. */
|
||||
export const commentBlock: Command = (view) => {
|
||||
if (view.state.readOnly) return false;
|
||||
const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
|
||||
return uncommentBlock(view, ranges) || toggleBlockComment(view);
|
||||
};
|
||||
|
||||
/** ⌘/ in a language without line comments (CSS, HTML…): the block comment of the lines. */
|
||||
function blockCommentLines(view: EditorView): boolean {
|
||||
return uncommentBlock(view, selectedLines(view.state)) || toggleComment(view);
|
||||
}
|
||||
|
||||
// --- Enter in a line comment ------------------------------------------------------
|
||||
|
||||
/** The start of the line comment the caret is in (after its prefix), else -1. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./editing";
|
||||
import { batchLanguage, commentTokens, findLanguageDescription } from "./languages";
|
||||
import { toggleCaret } from "./multiCaret";
|
||||
import { enterInLineComment, lineCommentStyle } from "./comments";
|
||||
import { commentBlock, enterInLineComment, lineCommentStyle } from "./comments";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
|
||||
/** Brace languages in miniature: one unit deeper after a line ending in `{`. */
|
||||
@@ -386,3 +386,131 @@ describe("Batch language mode and @REM comment toggle", () => {
|
||||
expect(has("labelName", ":sub")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle comment where the tokens depend on the syntax context", () => {
|
||||
// A JSX comment is `{/* … */}` in JSX children, but its own text parses as
|
||||
// JavaScript, where the tokens are `/* … */`.
|
||||
const wrap = (body: string) => `const a = (\n <div>\n ${body}\n </div>\n);`;
|
||||
|
||||
it("takes a JSX line comment back out, with the caret", async () => {
|
||||
const tsx = await languages.find((l) => l.name === "TSX")!.load();
|
||||
const v = make(wrap("|</div>"), tsx);
|
||||
commentLine(v);
|
||||
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
|
||||
commentLine(v);
|
||||
expect(show(v)).toBe(wrap("|</div>"));
|
||||
});
|
||||
|
||||
it("takes a JSX line comment back out, with the line selected", async () => {
|
||||
const tsx = await languages.find((l) => l.name === "TSX")!.load();
|
||||
const v = make(wrap("[</div>]"), tsx);
|
||||
commentLine(v);
|
||||
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
|
||||
commentLine(v);
|
||||
expect(v.state.doc.line(3).text).toBe(" </div>");
|
||||
});
|
||||
|
||||
it("takes a JSX block comment back out after ⌘⇧/", async () => {
|
||||
const tsx = await languages.find((l) => l.name === "TSX")!.load();
|
||||
const v = make(wrap("[</div>]"), tsx);
|
||||
commentBlock(v);
|
||||
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
|
||||
commentBlock(v);
|
||||
expect(show(v)).toBe(wrap("[</div>]"));
|
||||
});
|
||||
|
||||
it("takes a block comment back out in a language with no line comment", async () => {
|
||||
const html = await languages.find((l) => l.name === "HTML")!.load();
|
||||
const v = make("<section>\n |<p>hi</p>\n</section>", html);
|
||||
commentLine(v);
|
||||
expect(v.state.doc.line(2).text).toBe(" <!-- <p>hi</p> -->");
|
||||
commentLine(v);
|
||||
expect(show(v)).toBe("<section>\n |<p>hi</p>\n</section>");
|
||||
});
|
||||
|
||||
it("wraps several selected JSX lines once and unwraps them again", async () => {
|
||||
const tsx = await languages.find((l) => l.name === "TSX")!.load();
|
||||
const v = make("const a = (\n <div>\n [<b>x</b>\n <i>y</i>]\n </div>\n);", tsx);
|
||||
commentBlock(v);
|
||||
expect(v.state.doc.toString()).toContain("{/* <b>x</b>\n <i>y</i> */}");
|
||||
commentBlock(v);
|
||||
expect(v.state.doc.toString()).toBe("const a = (\n <div>\n <b>x</b>\n <i>y</i>\n </div>\n);");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment toggle regressions", () => {
|
||||
const cases = [
|
||||
["TSX", "const a = <div>\n [<b>x</b>\n <i>y</i>]\n</div>;"],
|
||||
["TSX", "const a = <div>{\n [value +\n other]\n}</div>;"],
|
||||
["TSX", "[const a = 1;\nconst b = 2;]"],
|
||||
["HTML", "<section>\n [<b>x</b>\n <i>y</i>]\n</section>"],
|
||||
["CSS", "[a { color: red; }\nb { color: blue; }]"],
|
||||
["Vue", "<template>\n[<b>x</b>\n<i>y</i>]\n</template>"],
|
||||
];
|
||||
|
||||
for (const [action, command] of [
|
||||
["line", commentLine],
|
||||
["block", commentBlock],
|
||||
] as const) {
|
||||
it.each(cases)(`${action} comments round trip in %s: %s`, async (language, input) => {
|
||||
const ext = await languages.find((l) => l.name === language)!.load();
|
||||
const v = make(input, ext);
|
||||
const original = v.state.doc.toString();
|
||||
expect(command(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).not.toBe(original);
|
||||
expect(command(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe(original);
|
||||
});
|
||||
|
||||
it.each(["TSX", "HTML", "CSS", "Vue"])(`${action} comments round trip with multiple ranges in %s`, async (name) => {
|
||||
const ext = await languages.find((l) => l.name === name)!.load();
|
||||
let body = name === "CSS" ? "[a { color: red; }]\n[b { color: blue; }]" : "[<b>x</b>]\n[<i>y</i>]";
|
||||
if (action === "line") body = body.replaceAll("[", "|").replaceAll("]", "");
|
||||
const input = name === "TSX" ? `const a = <div>\n${body}\n</div>;` : body;
|
||||
const v = make(input, ext);
|
||||
const original = v.state.doc.toString();
|
||||
expect(command(v)).toBe(true);
|
||||
expect(command(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe(original);
|
||||
expect(v.state.selection.ranges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CSS", "/*", "*/"],
|
||||
["JavaScript", "/*", "*/"],
|
||||
["HTML", "<!--", "-->"],
|
||||
["TSX", "{/*", "*/}"],
|
||||
])(`${action} comments do not join separate comments across live code in %s`, async (name, open, close) => {
|
||||
const ext = await languages.find((l) => l.name === name)!.load();
|
||||
for (const body of [`[${open} a ${close} b ${open} c ${close}]`, `${open} [a ${close} b ${open} c] ${close}`]) {
|
||||
const input = name === "TSX" ? `const a = <div>\n${body}\n</div>;` : body;
|
||||
const v = make(input, ext);
|
||||
const original = v.state.doc.toString();
|
||||
if (name === "JavaScript" && action === "line") {
|
||||
commentLine(v);
|
||||
commentLine(v);
|
||||
} else command(v);
|
||||
expect(v.state.doc.toString()).toBe(original);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a line-only language's block command unavailable", async () => {
|
||||
const ext = await languages.find((l) => l.name === "Python")!.load();
|
||||
const v = make("[a = 1\nb = 2]", ext);
|
||||
const original = v.state.doc.toString();
|
||||
expect(commentBlock(v)).toBe(false);
|
||||
expect(v.state.doc.toString()).toBe(original);
|
||||
expect(commentLine(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).not.toBe(original);
|
||||
expect(commentLine(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe(original);
|
||||
});
|
||||
|
||||
it("uncomments a parser-confirmed nested block comment", async () => {
|
||||
const ext = await languages.find((l) => l.name === "Rust")!.load();
|
||||
const v = make("[/* a /* b */ c */]", ext);
|
||||
expect(commentBlock(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("a /* b */ c");
|
||||
});
|
||||
});
|
||||
@@ -659,7 +659,7 @@ export function completeStatement(language: string): Command {
|
||||
|
||||
// --- comments ------------------------------------------------------------------------
|
||||
|
||||
export { commentLine } from "./comments";
|
||||
export { commentBlock, commentLine } from "./comments";
|
||||
|
||||
// --- smart keys ------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { PRESETS, useKeymap } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { multiCaretMouse } from "./multiCaret";
|
||||
|
||||
let view: EditorView;
|
||||
const originalKeymap = useKeymap.getState();
|
||||
const originalPlatform = usePlatform.getState();
|
||||
const event = (init: MouseEventInit = {}) => new MouseEvent("mousedown", { button: 0, altKey: true, ...init });
|
||||
const styles = (e: MouseEvent) => view.state.facet(EditorView.mouseSelectionStyle).map((style) => style(view, e));
|
||||
|
||||
beforeEach(() => {
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
usePlatform.setState({ os: "macos" });
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "first\nsecond\nthird",
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), multiCaretMouse],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
vi.spyOn(view, "posAtCoords").mockImplementation((coords) => coords.x);
|
||||
vi.spyOn(view, "coordsAtPos").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
view.destroy();
|
||||
useKeymap.setState(originalKeymap);
|
||||
usePlatform.setState(originalPlatform);
|
||||
});
|
||||
|
||||
describe("caret mouse bindings", () => {
|
||||
it.each(Object.keys(PRESETS))("%s keeps the default gesture", (preset) => {
|
||||
useKeymap.setState({ preset });
|
||||
expect(PRESETS[preset]!["editor.addOrRemoveCaret"]).toEqual(["Alt+Shift+Button1"]);
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the complete stroke and only the left button", () => {
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
for (const init of [{}, { shiftKey: true, ctrlKey: true }, { shiftKey: true, metaKey: true }, { button: 1 }]) {
|
||||
expect(styles(event(init))[0]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses rebinding immediately and yields column selection to the caret gesture", () => {
|
||||
expect(styles(event())[1]).not.toBeNull();
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
|
||||
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
|
||||
const [caret, column] = styles(event({ clientX: 2 }));
|
||||
expect(column).toBeNull();
|
||||
expect(caret!.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0, 2]);
|
||||
const drag = caret!.get(event({ clientX: 8 }), false, false);
|
||||
expect(drag.ranges.map((r) => [r.anchor, r.head])).toEqual([
|
||||
[0, 0],
|
||||
[2, 8],
|
||||
]);
|
||||
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(2)]) });
|
||||
const remove = styles(event({ clientX: 2 }))[0]!;
|
||||
expect(remove.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("honours removal and restores Alt-drag column selection", () => {
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": [] } });
|
||||
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
|
||||
expect(styles(event())[0]).toBeNull();
|
||||
expect(styles(event())[1]).not.toBeNull();
|
||||
useKeymap.setState({ overrides: {} });
|
||||
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches normalized Ctrl+Alt strokes as reported for right Alt on Windows", () => {
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["alt+ctrl+button1"] } });
|
||||
expect(styles(event({ ctrlKey: true }))[0]).not.toBeNull();
|
||||
expect(styles(event())[0]).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves middle-button column selection except on Linux", () => {
|
||||
expect(styles(event({ button: 1, altKey: false }))[1]).not.toBeNull();
|
||||
usePlatform.setState({ os: "linux" });
|
||||
expect(styles(event({ button: 1, altKey: false }))[1]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
/**
|
||||
* IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or
|
||||
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and
|
||||
* middle-button drag make a column (rectangular) selection. Middle-button
|
||||
* drag is left alone on Linux, where a middle click pastes the primary selection.
|
||||
*/
|
||||
/** Mouse caret gestures follow the keymap; Alt and middle-button drags select columns. */
|
||||
import { EditorSelection, type Extension } from "@codemirror/state";
|
||||
import { EditorView, type MouseSelectionStyle } from "@codemirror/view";
|
||||
import { bindingsFor, mouseStrokeOf, normalizeBinding } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { blockMouseSelection } from "./columnMode";
|
||||
|
||||
/** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */
|
||||
/** The selection after a caret click / drag from `anchor` to `head` on top of `start`. */
|
||||
export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection {
|
||||
if (anchor === head) {
|
||||
const hit = start.ranges.findIndex((r) => r.empty && r.head === head);
|
||||
@@ -21,8 +17,16 @@ export function toggleCaret(start: EditorSelection, anchor: number, head: number
|
||||
return start.addRange(EditorSelection.range(anchor, head));
|
||||
}
|
||||
|
||||
export function isAddCaretGesture(event: MouseEvent): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
const stroke = mouseStrokeOf(event);
|
||||
return (
|
||||
stroke !== null && bindingsFor("editor.addOrRemoveCaret").some((binding) => normalizeBinding(binding) === stroke)
|
||||
);
|
||||
}
|
||||
|
||||
const addCaretStyle = EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => {
|
||||
if (event.button !== 0 || !event.altKey || !event.shiftKey || event.metaKey || event.ctrlKey) return null;
|
||||
if (!isAddCaretGesture(event)) return null;
|
||||
let start = view.state.selection;
|
||||
const anchor = view.posAtCoords({ x: event.clientX, y: event.clientY }, false);
|
||||
return {
|
||||
@@ -41,7 +45,7 @@ export const multiCaretMouse: Extension = [
|
||||
// CodeMirror's default (⌘click) would clash with ⌘click navigation.
|
||||
EditorView.clickAddsSelectionRange.of(() => false),
|
||||
blockMouseSelection((e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (isAddCaretGesture(e) || e.shiftKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (e.button === 0) return e.altKey;
|
||||
return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux";
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerFileEditorProvider } from "@/workbench/contributions";
|
||||
import { EditorController, openFilePaths, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { storeSession } from "./model/session";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
import { useProject } from "@/platform/project";
|
||||
|
||||
@@ -63,6 +64,25 @@ function tabFor(path: string) {
|
||||
}
|
||||
|
||||
describe("file open order", () => {
|
||||
it("keeps the caret and activation of a file opened while the project's session restores", async () => {
|
||||
const before = setup();
|
||||
before.restoreSession("/p");
|
||||
await before.openFile("/p/b.ts", { focus: false });
|
||||
before.stop();
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
try {
|
||||
const controller = setup();
|
||||
const open = controller.openFile("/p/a.ts", { selection: { line: 1, col: 2 }, focus: false });
|
||||
controller.restoreSession("/p");
|
||||
await open;
|
||||
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
|
||||
expect(activePath()).toBe("/p/a.ts");
|
||||
expect(controller.slotFor(tabFor("/p/a.ts")!.id)?.caret()).toMatchObject({ line: 1, col: 2 });
|
||||
} finally {
|
||||
storeSession("/p", null);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => {
|
||||
useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never });
|
||||
const { started, response } = delayProvider();
|
||||
|
||||
@@ -74,6 +74,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover";
|
||||
import { declarationSummary, renderCode } from "./markdown";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
|
||||
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
|
||||
import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
|
||||
import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { create } from "zustand";
|
||||
@@ -710,6 +711,7 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
},
|
||||
mousedown(e, view) {
|
||||
if (e.button !== 0 && e.button !== 1) return false;
|
||||
if (isAddCaretGesture(e)) return false;
|
||||
if (
|
||||
dispatchMouseShortcut(e, () => {
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.split {
|
||||
display: flex;
|
||||
min-width: 640px;
|
||||
flex: none;
|
||||
min-height: 320px;
|
||||
border: 1px solid var(--border-strong);
|
||||
|
||||
@@ -132,21 +132,19 @@
|
||||
/* Scrolled up: one click back to the latest message, floating over the stream's end. */
|
||||
.jumpToLatest {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--space-3);
|
||||
transform: translateX(-50%);
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-2);
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: var(--h-control-small);
|
||||
height: var(--h-control-small);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-popup);
|
||||
color: var(--fg);
|
||||
box-shadow: var(--shadow-popup);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-toolbar-button);
|
||||
background: var(--bg-app);
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.jumpToLatest:hover {
|
||||
@@ -230,18 +228,14 @@
|
||||
}
|
||||
.conversationRow > .process,
|
||||
.conversationRow > .permissionLine {
|
||||
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
|
||||
max-width: 100%;
|
||||
}
|
||||
/* Replies read as plain text on the panel, like other tool-window content. */
|
||||
.agentCard {
|
||||
box-sizing: border-box;
|
||||
width: fit-content;
|
||||
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
@container (max-width: 640px) {
|
||||
.stream {
|
||||
@@ -258,6 +252,14 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.messageFooter > .messageTime {
|
||||
opacity: 0;
|
||||
}
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageFooter > .messageTime,
|
||||
.message:hover .messageFooter > .messageTime,
|
||||
.agentMessage:hover .messageFooter > .messageTime,
|
||||
.message:focus-within .messageFooter > .messageTime,
|
||||
.agentMessage:focus-within .messageFooter > .messageTime,
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
|
||||
.message:hover .messageActions,
|
||||
.reply:hover .messageActions,
|
||||
@@ -327,11 +329,10 @@
|
||||
.user {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-tree-selected-inactive);
|
||||
color: var(--fg-strong);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-tab);
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -24,3 +24,16 @@ it("preserves external agent errors", async () => {
|
||||
const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed"));
|
||||
expect(result.current).toBe("Agent says: authentication failed");
|
||||
});
|
||||
|
||||
it("translates only the guidance when the agent adds its own explanation", async () => {
|
||||
await setLocale("zh");
|
||||
const details = "Authentication failed: token expired.\nRun `account repair` in a terminal.";
|
||||
const { result } = renderHook(() =>
|
||||
useAgentErrorText(
|
||||
`Google Antigravity needs you to sign in. Run \`'/registry/antigravity' auth login\` in a terminal.\n\n${details}`,
|
||||
),
|
||||
);
|
||||
expect(result.current).toBe(
|
||||
`Google Antigravity 需要你登录。在终端中运行 \`'/registry/antigravity' auth login\`。\n\n${details}`,
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,6 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
|
||||
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;
|
||||
const auth = /^(.+?) needs you to sign in\. ([^\n]+)([\s\S]*)$/.exec(message);
|
||||
return auth ? `${tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}`}${auth[3]}` : message;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
|
||||
const { install, uninstall, notify } = vi.hoisted(() => ({ install: vi.fn(), uninstall: vi.fn(), notify: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
call: (result: Promise<unknown>) => result,
|
||||
commands: { appCliInstall: install, appCliUninstall: uninstall },
|
||||
isCancelled: (error: unknown) => (error as { kind?: string })?.kind === "cancelled",
|
||||
}));
|
||||
|
||||
const { cliActions } = await import("../cliCommand");
|
||||
const run = (id: string) =>
|
||||
cliActions()
|
||||
.find((action) => action.id === id)!
|
||||
.run({ ctx: getContext() });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
provideNotificationService({ notify, expire: () => {} });
|
||||
});
|
||||
|
||||
it("explains the Linux PATH step when the user bin is missing from PATH", async () => {
|
||||
install.mockResolvedValue({ path: "/home/user/.local/bin/openidea", pathDirectory: "/home/user/.local/bin" });
|
||||
await run("app.installCli");
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: "info",
|
||||
body: expect.stringContaining("Add /home/user/.local/bin to the beginning of PATH"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tells users to open a new terminal after installation", async () => {
|
||||
install.mockResolvedValue({ path: "C:/Users/user/OpenIDEA/bin/openidea.cmd", pathDirectory: null });
|
||||
await run("app.installCli");
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: expect.stringContaining("Open a new terminal") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an absent command on repeated uninstall", async () => {
|
||||
uninstall.mockResolvedValue([]);
|
||||
await run("app.uninstallCli");
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "The openidea command is not installed" }));
|
||||
});
|
||||
|
||||
it("reports an install failure and stays quiet when an administrator prompt is cancelled", async () => {
|
||||
install.mockRejectedValueOnce(new Error("foreign command"));
|
||||
await run("app.installCli");
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "foreign command" }));
|
||||
notify.mockClear();
|
||||
install.mockRejectedValueOnce({ kind: "cancelled" });
|
||||
await run("app.installCli");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Install / Uninstall the `openidea` shell command. */
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
const KEY = "app.cli";
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
async function installCli(): Promise<void> {
|
||||
try {
|
||||
const result = await call(commands.appCliInstall());
|
||||
const hint = result.pathDirectory
|
||||
? tx`Add ${result.pathDirectory} to the beginning of PATH, then open a new terminal.`
|
||||
: tx("Open a new terminal to use the openidea command.");
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: tx("The openidea command is installed"),
|
||||
body: `${result.path}\n${hint}`,
|
||||
key: KEY,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot install the openidea command"),
|
||||
body: errorText(e),
|
||||
key: KEY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallCli(): Promise<void> {
|
||||
try {
|
||||
const links = await call(commands.appCliUninstall());
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: tx(links.length ? "The openidea command is uninstalled" : "The openidea command is not installed"),
|
||||
body: links.join("\n") || undefined,
|
||||
key: KEY,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot uninstall the openidea command"),
|
||||
body: errorText(e),
|
||||
key: KEY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function cliActions(): Action[] {
|
||||
return [
|
||||
{
|
||||
id: "app.installCli",
|
||||
title: tx("Install 'openidea' Command"),
|
||||
category: tx("Tools"),
|
||||
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
|
||||
run: installCli,
|
||||
},
|
||||
{
|
||||
id: "app.uninstallCli",
|
||||
title: tx("Uninstall 'openidea' Command"),
|
||||
category: tx("Tools"),
|
||||
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
|
||||
run: uninstallCli,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -189,6 +189,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
// screen (to open another one); closing the welcome screen, or quitting, closes the window.
|
||||
if (!ctx.quitting && ctx.windows <= 1 && (s.project || s.lightEdit)) {
|
||||
if (await confirmLeave(deps)) await closeProject();
|
||||
await commands.windowGroupCancelClose();
|
||||
return false;
|
||||
}
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
@@ -205,7 +206,6 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
deps,
|
||||
);
|
||||
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
|
||||
else if (!ctx.quitting) await commands.windowGroupBeforeClose();
|
||||
return ok;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
|
||||
import { AboutOverlay } from "./AboutDialog";
|
||||
import { appActions, updateActions } from "./appActions";
|
||||
import { checkForUpdatesOnStartup } from "./updates";
|
||||
import { cliActions } from "./cliCommand";
|
||||
import { installCloseGuard } from "./closeGuard";
|
||||
import { TerminationOverlay } from "./termination";
|
||||
import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches";
|
||||
@@ -31,6 +32,7 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
installLocaleSync(),
|
||||
registerLocalizedActions(() => [...appActions(), ...windowActions()]),
|
||||
registerLocalizedActions(cliActions),
|
||||
registerOverlay({ id: "app.about", component: AboutOverlay }),
|
||||
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
|
||||
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
|
||||
|
||||
@@ -350,11 +350,15 @@
|
||||
/* Dialogs. */
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
grid-template-columns: fit-content(40%) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 440px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.form > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.formLabel {
|
||||
color: var(--fg-secondary);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setContext } from "@/platform/context";
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { menuGroupItems } from "@/workbench/contributions";
|
||||
import { multiCaretMouse } from "@/editor/core/cm/multiCaret";
|
||||
import { navigationExtension } from "@/editor/lsp/navigation";
|
||||
import { activate } from "../index";
|
||||
import { adoptSession, publishContext, useDebug } from "../state";
|
||||
@@ -45,7 +46,7 @@ function session(adapter = "debugpy", status: "paused" | "running" | "terminated
|
||||
publishContext();
|
||||
}
|
||||
function click() {
|
||||
const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true });
|
||||
const e = new MouseEvent("mousedown", { button: 0, altKey: true, detail: 1, bubbles: true, cancelable: true });
|
||||
view.contentDOM.dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
@@ -58,7 +59,15 @@ beforeEach(() => {
|
||||
dispose = activate();
|
||||
uninstall = installKeyboardDispatcher(window);
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }),
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
navigationExtension(),
|
||||
quickEvaluateMouse(),
|
||||
multiCaretMouse,
|
||||
],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
editor.current = { view, ctx: { path: "/p/app.py" } };
|
||||
@@ -133,6 +142,32 @@ describe("paused editor evaluation", () => {
|
||||
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"adds a caret without dispatching an action after removing the evaluation conflict (paused: %s)",
|
||||
(paused) => {
|
||||
if (paused) session();
|
||||
const run = vi.fn();
|
||||
const remove = registerAction({ id: "editor.addOrRemoveCaret", title: "Add or Remove Caret", run });
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"], "debug.quickEvaluate": [] } });
|
||||
try {
|
||||
click();
|
||||
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([0, 16]);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
remove();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not also add a caret when an enabled evaluation handles a shared stroke", async () => {
|
||||
session();
|
||||
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
|
||||
click();
|
||||
await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledOnce());
|
||||
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([16]);
|
||||
});
|
||||
|
||||
it("honours removal of the mouse binding", () => {
|
||||
session();
|
||||
useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } });
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
* (opened by `openPullRequestFileDiff` / the details panel's commit diffs, context
|
||||
* `PULL_REQUEST_DIFF`) get their review threads as inlays, the gutter "+" with single- and
|
||||
* multi-line comments, Start Review / Add Single Comment, the review toolbar and the viewed
|
||||
* toggle, through the vcs diff viewer provider point. Also registers the editor actions and
|
||||
* Settings › Version Control › GitHub. The editor actions also serve review mode's editors.
|
||||
* toggle, through the vcs diff viewer provider point. The editor actions also serve review mode's editors.
|
||||
*/
|
||||
import { registerDiffViewerProvider } from "@/features/vcs/diffViewerProviders";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
import { useGhRepository } from "../data/repository";
|
||||
import { activeReviewEditor } from "../reviewMode/editor";
|
||||
import { clearNewComments } from "./newComments";
|
||||
@@ -82,13 +79,6 @@ export function activateReviewDiff(): () => void {
|
||||
run: () => reviewTarget()?.refresh(),
|
||||
},
|
||||
]),
|
||||
registerSettingsPage({
|
||||
id: "vcs.github",
|
||||
title: "GitHub",
|
||||
order: 9,
|
||||
keywords: ["pull request", "viewed", "review"],
|
||||
component: lazyComponent(() => import("./GithubSettingsPage").then((m) => m.GithubSettingsPage)),
|
||||
}),
|
||||
useGhRepository.subscribe((s, prev) => {
|
||||
const a = s.connection?.coordinates;
|
||||
const b = prev.connection?.coordinates;
|
||||
|
||||
@@ -46,6 +46,10 @@ function load(): ReviewDiffSettings {
|
||||
|
||||
export const useReviewDiffSettings = create<ReviewDiffSettings>(load);
|
||||
|
||||
globalThis.addEventListener?.("storage", (event: StorageEvent) => {
|
||||
if (event.key === KEY || event.key === null) useReviewDiffSettings.setState(load(), true);
|
||||
});
|
||||
|
||||
export function setReviewDiffSettings(patch: Partial<ReviewDiffSettings>) {
|
||||
useReviewDiffSettings.setState(patch);
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
import { useGithubShell } from "./shell";
|
||||
import { githubHandler, handlerContextKey } from "./toolwindow/handlers";
|
||||
import { GithubDialogHost } from "./ui/dialogs";
|
||||
import { activateSettingsPages } from "./settingsPages";
|
||||
|
||||
export { activateSettingsPages } from "./settingsPages";
|
||||
|
||||
const WINDOW = "pullRequests";
|
||||
const TIMELINE_KIND = "github.timeline";
|
||||
@@ -255,6 +258,7 @@ export function activate(): () => void {
|
||||
active = true;
|
||||
setContext("githubPrTab", "list");
|
||||
const disposers = [
|
||||
activateSettingsPages(),
|
||||
registerCloneSource({
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useSettingsPages } from "@/workbench/contributions";
|
||||
import { activateSettingsPages } from "./settingsPages";
|
||||
import { GithubSettingsPage } from "./diff/GithubSettingsPage";
|
||||
import { setReviewDiffSettings, useReviewDiffSettings } from "./diff/settings";
|
||||
|
||||
const KEY = "openidea.github.reviewDiff.v1";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(KEY);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
|
||||
});
|
||||
|
||||
describe("GitHub settings pages", () => {
|
||||
it("registers independently of the pull request runtime", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
expect(useSettingsPages.getState().pages.get("vcs.github")).toMatchObject({ title: "GitHub" });
|
||||
expect(useSettingsPages.getState().pages.get("vcs.github")?.component).toBeDefined();
|
||||
dispose();
|
||||
expect(useSettingsPages.getState().pages.has("vcs.github")).toBe(false);
|
||||
});
|
||||
|
||||
it("updates an open page when another window saves or clears review settings", () => {
|
||||
render(<GithubSettingsPage />);
|
||||
const checkbox = screen.getByRole("checkbox", { name: "Automatically mark opened files as viewed" });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
act(() => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ autoMarkViewed: true, threadsView: "all" }));
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
|
||||
});
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(useReviewDiffSettings.getState().threadsView).toBe("all");
|
||||
act(() => {
|
||||
localStorage.clear();
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: null }));
|
||||
});
|
||||
expect(checkbox).not.toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(JSON.parse(localStorage.getItem(KEY)!)).toMatchObject({ autoMarkViewed: true });
|
||||
});
|
||||
|
||||
it("ignores unrelated storage changes and recovers from invalid saved settings", () => {
|
||||
setReviewDiffSettings({ autoMarkViewed: true });
|
||||
localStorage.removeItem(KEY);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "other" }));
|
||||
expect(useReviewDiffSettings.getState().autoMarkViewed).toBe(true);
|
||||
localStorage.setItem(KEY, "invalid JSON");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
|
||||
expect(useReviewDiffSettings.getState().autoMarkViewed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerSettingsPage } from "@/workbench/contributions";
|
||||
|
||||
export function activateSettingsPages(): () => void {
|
||||
return registerSettingsPage({
|
||||
id: "vcs.github",
|
||||
title: "GitHub",
|
||||
order: 9,
|
||||
keywords: ["pull request", "viewed", "review"],
|
||||
component: lazyComponent(() => import("./diff/GithubSettingsPage").then((m) => m.GithubSettingsPage)),
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import { activate as notebook } from "./notebook";
|
||||
import { activate as web } from "./web";
|
||||
import { activate as analysis } from "./analysis";
|
||||
import { activate as services } from "./services";
|
||||
import { activate as github } from "./github";
|
||||
import { activate as github, activateSettingsPages as githubPages } from "./github";
|
||||
import { activate as httpClient } from "./http-client";
|
||||
import { activate as database } from "./database";
|
||||
import { activate as graphql } from "./graphql";
|
||||
@@ -166,6 +166,7 @@ export function activateSettingsWindowFeatures(parent: string): () => void {
|
||||
["agents", agentsPages],
|
||||
["vim", vimPages],
|
||||
["vcs", vcsPages],
|
||||
["github", githubPages],
|
||||
];
|
||||
return activateAll("settings pages", pages);
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
min-width: 640px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-small);
|
||||
|
||||
@@ -225,6 +225,17 @@ export function sampleFs(root: string): MemoryFs {
|
||||
f("build.gradle.kts"),
|
||||
f("settings.gradle.kts"),
|
||||
f("README.md"),
|
||||
f(
|
||||
"App.tsx",
|
||||
`export function App() {
|
||||
return (
|
||||
<div>
|
||||
<b>x</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
),
|
||||
...Array.from({ length: 10_000 }, (_, i) => f(`generated/item${i + 1}.json`)),
|
||||
]),
|
||||
root,
|
||||
|
||||
@@ -193,13 +193,32 @@ describe("context configuration", () => {
|
||||
it("runs a JS test file through the closest test script", () => {
|
||||
const c = contextConfig("web/src/cart.test.ts", configs)!;
|
||||
expect(c).toMatchObject({
|
||||
args: ["run", "test", "--", "src/cart.test.ts"],
|
||||
args: ["run", "test", "src/cart.test.ts"],
|
||||
cwd: "web",
|
||||
isTest: true,
|
||||
name: "cart.test.ts",
|
||||
});
|
||||
expect(c.id).toBe("context:npm:web:test:web/src/cart.test.ts");
|
||||
expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]);
|
||||
expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "src/a.spec.js"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["npm", ["--"]],
|
||||
["pnpm", []],
|
||||
["/opt/bin/pnpm", []],
|
||||
["C:\\tools\\pnpm.CMD", []],
|
||||
["yarn", ["--"]],
|
||||
["bun", ["--"]],
|
||||
])("forwards test files through %s", (command, separator) => {
|
||||
const base = cfg({ id: "js:test", kind: "npm", command, args: ["run", "test"], isTest: true });
|
||||
expect(contextConfig("tests/cart.test.ts", [base])!.args).toEqual([
|
||||
"run",
|
||||
"test",
|
||||
...separator,
|
||||
"tests/cart.test.ts",
|
||||
]);
|
||||
const explicit = { ...base, args: ["run", "test", "--", "--reporter=verbose"] };
|
||||
expect(contextConfig("tests/cart.test.ts", [explicit])!.args).toEqual([...explicit.args, "tests/cart.test.ts"]);
|
||||
});
|
||||
|
||||
it("narrows rspec and rake test runs to the file", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { goBuildType } from "./goTypes";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
import { typeFor } from "./types";
|
||||
import { runTemplate } from "./runTemplates";
|
||||
import { appendScriptArgs } from "./scriptArgs";
|
||||
|
||||
export type FileRole = "test" | "main" | "script" | null;
|
||||
|
||||
@@ -216,8 +217,7 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
|
||||
switch (base.kind) {
|
||||
case "npm":
|
||||
case "bun": {
|
||||
// `npm run test -- file` works for npm, pnpm, yarn and bun.
|
||||
const args = base.args.includes("--") ? [...base.args, file] : [...base.args, "--", file];
|
||||
const args = appendScriptArgs(base.command, base.args, [file]);
|
||||
return derived(base, relPath, { args });
|
||||
}
|
||||
case "deno":
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { commandName } from "./commandName";
|
||||
|
||||
/** pnpm forwards `--` to the script, where it ends the test runner's option parsing. */
|
||||
export function appendScriptArgs(command: string, args: string[], extra: string[]): string[] {
|
||||
const separator = !args.includes("--") && ["npm", "yarn", "bun"].includes(commandName(command)) ? ["--"] : [];
|
||||
return [...args, ...separator, ...extra];
|
||||
}
|
||||
@@ -170,7 +170,6 @@ describe("targetConfig", () => {
|
||||
expect(first("describe('m', () => {\n it('adds', () => {});\n});", "src/a.test.ts").args).toEqual([
|
||||
"run",
|
||||
"test",
|
||||
"--",
|
||||
"src/a.test.ts",
|
||||
"-t",
|
||||
"^m adds$",
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user