mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+37
-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",
|
||||
@@ -4170,7 +4170,7 @@ dependencies = [
|
||||
|
||||
[[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 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4196,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4210,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4231,7 +4231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4258,7 +4258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4271,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4291,7 +4291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4303,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4316,7 +4316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4396,7 +4396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4418,7 +4418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4435,7 +4435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4491,7 +4491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4517,7 +4517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4540,7 +4540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4561,7 +4561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4574,7 +4574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4592,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4616,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4635,7 +4635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4655,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4668,7 +4668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4689,7 +4689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4704,7 +4704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4717,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4730,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4751,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4772,7 +4772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4796,7 +4796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4812,7 +4812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6358,7 +6358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.38"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-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"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -1581,6 +1581,7 @@ impl Task {
|
||||
.collect()
|
||||
};
|
||||
if due.is_empty() {
|
||||
self.approve_pending();
|
||||
return;
|
||||
}
|
||||
let task = self.clone();
|
||||
@@ -1610,6 +1611,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 +1728,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 +1862,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();
|
||||
|
||||
@@ -35,6 +35,8 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
/// 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>>,
|
||||
@@ -128,6 +130,13 @@ impl Handler for FakeAgent {
|
||||
{
|
||||
return Err((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 {
|
||||
"initialize" => {
|
||||
@@ -200,6 +209,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()
|
||||
@@ -1438,12 +1470,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 +1566,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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("Add Python Interpreter keeps environment labels and fields inside their rows", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await settings.getByRole("textbox", { name: "Search settings" }).fill("Python Interpreter");
|
||||
await settings
|
||||
.getByRole("tree", { name: "Settings pages" })
|
||||
.getByRole("treeitem", { name: "Python Interpreter", exact: true })
|
||||
.click();
|
||||
await settings.getByRole("button", { name: "Add Interpreter…", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Add Python Interpreter", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const width of [1000, 800, 520]) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
await expect(async () => {
|
||||
const labelsFit = await dialog.getByRole("list", { name: "Interpreter type" }).evaluate((list) =>
|
||||
[...list.querySelectorAll("button")].every((button) => {
|
||||
const box = button.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
return [...range.getClientRects()].every(
|
||||
(r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1 && r.right <= box.right + 1,
|
||||
);
|
||||
}),
|
||||
);
|
||||
expect(labelsFit).toBe(true);
|
||||
}).toPass({ timeout: 3000 });
|
||||
|
||||
const location = dialog.getByRole("textbox", { name: "Location:", exact: true });
|
||||
const fieldSpacing = await location.evaluate((input) => ({
|
||||
rowHeight: input.parentElement!.getBoundingClientRect().height,
|
||||
fieldHeight: input.getBoundingClientRect().height,
|
||||
}));
|
||||
expect(fieldSpacing.rowHeight).toBeLessThanOrEqual(fieldSpacing.fieldHeight + 1);
|
||||
const base = await dialog.getByRole("button", { name: "Base interpreter", exact: true }).boundingBox();
|
||||
const loc = await location.boundingBox();
|
||||
expect(base!.y - loc!.y - loc!.height).toBeLessThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
test("Project Structure keeps edits across repeated shortcuts and applies them", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+;");
|
||||
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -82,8 +82,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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
+11
-2
@@ -190,6 +190,10 @@ 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);
|
||||
if let Some(paths) = setup_opens.lock().take() {
|
||||
@@ -201,8 +205,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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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$",
|
||||
|
||||
@@ -86,15 +86,23 @@ describe("rerunFailedConfig", () => {
|
||||
expect(c.args).toEqual(["test", "--", "--nocapture", "old", "a", "--exact"]);
|
||||
});
|
||||
|
||||
it("jest/vitest via npm: files + -t pattern", () => {
|
||||
const c = rerunFailedConfig(cfg("npm", "pnpm", ["run", "test"]), [
|
||||
it.each([
|
||||
["npm", ["--"]],
|
||||
["pnpm", []],
|
||||
["/opt/bin/pnpm", []],
|
||||
["C:\\tools\\pnpm.CMD", []],
|
||||
["yarn", ["--"]],
|
||||
["bun", ["--"]],
|
||||
["npx", []],
|
||||
])("jest/vitest via %s: files + -t pattern", (command, separator) => {
|
||||
const c = rerunFailedConfig(cfg("npm", command, ["run", "test"]), [
|
||||
["src/a.test.ts", "math", "adds (1+1)"],
|
||||
["src/b.test.ts"],
|
||||
])!;
|
||||
expect(c.args).toEqual([
|
||||
"run",
|
||||
"test",
|
||||
"--",
|
||||
...separator,
|
||||
"src/a.test.ts",
|
||||
"src/b.test.ts",
|
||||
"-t",
|
||||
@@ -102,6 +110,14 @@ describe("rerunFailedConfig", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["run", "test", "-t", "old"],
|
||||
["run", "test", "--", "--testNamePattern=old"],
|
||||
])("replaces an existing pnpm test filter in %j", (...args) => {
|
||||
const c = rerunFailedConfig(cfg("npm", "pnpm", args), [["src/a.test.ts", "new"]])!;
|
||||
expect(c.args).toEqual(["run", "test", ...(args.includes("--") ? ["--"] : []), "src/a.test.ts", "-t", "^(?:new)$"]);
|
||||
});
|
||||
|
||||
it("pytest: node ids replace explicit paths", () => {
|
||||
const c = rerunFailedConfig(cfg("pytest", ".venv/bin/pytest", ["-q", "tests/", "-k", "slow"]), [
|
||||
["tests/test_x.py", "TestC", "test_y[1]"],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { phpunitArgs, phpunitFilter } from "./phpunit";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { formatArgs } from "../configs";
|
||||
import { appendScriptArgs } from "../scriptArgs";
|
||||
import { djangoTestsOf } from "../djangoTypes";
|
||||
import { goTestParts, withoutGoSelection } from "../goArguments";
|
||||
import { frameworkFor } from "./index";
|
||||
@@ -71,11 +72,10 @@ function jsArgs(config: RunConfig, paths: string[][]): string[] | null {
|
||||
const names = unique(paths.filter((p) => p.length > 1).map((p) => p.slice(1).join(" ")));
|
||||
if (!files.length) return null;
|
||||
const { before, after } = splitDashDash(base);
|
||||
const rest = dropFlag(after ?? [], ["-t", "--testNamePattern"]);
|
||||
const flags = ["-t", "--testNamePattern"];
|
||||
const args = after === null ? dropFlag(before, flags) : [...before, "--", ...dropFlag(after, flags)];
|
||||
const filter = names.length ? ["-t", `^(?:${names.map(escapeRegExp).join("|")})$`] : [];
|
||||
// npm/pnpm/yarn/bun pass everything after `--` to the script; deno/npx take them directly.
|
||||
const needsDashDash = after !== null || /(^|\/)(npm|pnpm|yarn|bun)$/.test(config.command);
|
||||
return needsDashDash ? [...before, "--", ...rest, ...files, ...filter] : [...before, ...files, ...filter];
|
||||
return appendScriptArgs(config.command, args, [...files, ...filter]);
|
||||
}
|
||||
|
||||
function pytestArgs(base: string[], paths: string[][]): string[] | null {
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("theme tokens", () => {
|
||||
* on purpose: colours the user assigns (project and data source colours), the
|
||||
* platform's caption close and macOS window buttons, and the tint strength.
|
||||
*/
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/;
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
|
||||
|
||||
describe("palette themes", () => {
|
||||
const ISLANDS = ["light", "dark", "dark-hc"];
|
||||
|
||||
@@ -75,9 +75,10 @@
|
||||
.pageHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px 0 20px;
|
||||
}
|
||||
.history {
|
||||
@@ -120,6 +121,7 @@
|
||||
}
|
||||
.scope {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
@@ -130,6 +132,7 @@
|
||||
border-radius: 3px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.scopeButton:hover {
|
||||
color: var(--fg-emphasis);
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.presetLabel {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
}
|
||||
.presetSelect {
|
||||
|
||||
@@ -226,7 +226,7 @@ export function AddInterpreterDialog({
|
||||
<label htmlFor={ids.existing}>
|
||||
<T>Interpreter:</T>
|
||||
</label>
|
||||
<div className={`${styles.pathRow} ${styles.existingPathRow}`}>
|
||||
<div className={styles.pathRow}>
|
||||
<Select
|
||||
id={ids.existing}
|
||||
className={styles.pathInput}
|
||||
|
||||
@@ -9,21 +9,25 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
max-width: 560px;
|
||||
}
|
||||
.sdkSelect {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.interpreterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--h-row);
|
||||
}
|
||||
.interpreterRow > .sdkField {
|
||||
flex-basis: 260px;
|
||||
}
|
||||
.interpreterLabel {
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
@@ -152,7 +156,7 @@
|
||||
/* Add Python Interpreter dialog */
|
||||
.dialogBody {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(150px, max-content) minmax(0, 1fr);
|
||||
min-height: 300px;
|
||||
}
|
||||
.kinds {
|
||||
@@ -174,6 +178,7 @@
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kindOn {
|
||||
background: var(--bg-list-selected);
|
||||
@@ -187,12 +192,14 @@
|
||||
}
|
||||
.radios {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
}
|
||||
.radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pathRow {
|
||||
display: flex;
|
||||
@@ -201,7 +208,7 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.existingPathRow {
|
||||
.form > .pathRow {
|
||||
flex: none;
|
||||
}
|
||||
.pathInput {
|
||||
@@ -212,7 +219,7 @@
|
||||
/* Project Structure */
|
||||
.structure {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 220px;
|
||||
grid-template-columns: minmax(240px, 1fr) 220px;
|
||||
gap: 10px;
|
||||
min-height: 420px;
|
||||
}
|
||||
@@ -418,7 +425,7 @@
|
||||
}
|
||||
.splitLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 268px minmax(0, 1fr);
|
||||
grid-template-columns: 268px minmax(300px, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
/* Inline option groups, with custom font controls wrapping together. */
|
||||
.fontRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
@@ -93,11 +94,14 @@
|
||||
|
||||
.radios {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.radio {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -111,6 +115,7 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th {
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--fg-muted);
|
||||
@@ -119,6 +124,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td {
|
||||
white-space: nowrap;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
grid-template-columns: 200px minmax(220px, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,13 @@
|
||||
/* "[x] Limit history to: [1000] rows" */
|
||||
.fontFamily {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.inlineRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
@@ -166,6 +168,21 @@
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
.toolRow > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolName {
|
||||
flex: 1;
|
||||
}
|
||||
.toolRow.empty {
|
||||
height: auto;
|
||||
min-height: var(--h-list-row);
|
||||
padding-block: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.toolRow:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
@@ -174,6 +191,7 @@
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.toolGroup {
|
||||
max-width: 45%;
|
||||
color: var(--fg-subtle);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
}
|
||||
.schemeBody {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 260px);
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(220px, 260px);
|
||||
gap: 14px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
.profileSelect {
|
||||
min-width: 220px;
|
||||
}
|
||||
.profileRow > span {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -27,6 +31,7 @@
|
||||
}
|
||||
.split {
|
||||
flex: 1;
|
||||
min-width: 520px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -491,8 +491,13 @@ function RunTargetsEditor() {
|
||||
className={clsx(styles.toolRow, t.id === selected && styles.toolRowSelected)}
|
||||
onMouseDown={() => setSelected(t.id)}
|
||||
>
|
||||
<span>{t.name || t.id}</span>
|
||||
<span className={styles.toolGroup}>
|
||||
<span className={styles.toolName} title={t.name || t.id}>
|
||||
{t.name || t.id}
|
||||
</span>
|
||||
<span
|
||||
className={styles.toolGroup}
|
||||
title={t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
|
||||
>
|
||||
{t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -339,8 +339,12 @@ function SshConfigsEditor() {
|
||||
className={clsx(styles.toolRow, c.id === selected && styles.toolRowSelected)}
|
||||
onMouseDown={() => setSelected(c.id)}
|
||||
>
|
||||
<span>{c.name.trim() || sshLabel(c) || c.id}</span>
|
||||
<span className={styles.toolGroup}>{sshLabel(c)}</span>
|
||||
<span className={styles.toolName} title={c.name.trim() || sshLabel(c) || c.id}>
|
||||
{c.name.trim() || sshLabel(c) || c.id}
|
||||
</span>
|
||||
<span className={styles.toolGroup} title={sshLabel(c)}>
|
||||
{sshLabel(c)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -480,8 +480,12 @@ function ExternalToolsEditor() {
|
||||
checked={t.enabled}
|
||||
onChange={(on) => setTools(tools.map((x) => (x.id === t.id ? { ...x, enabled: on } : x)))}
|
||||
/>
|
||||
<span>{t.name}</span>
|
||||
<span className={styles.toolGroup}>{t.group}</span>
|
||||
<span className={styles.toolName} title={t.name}>
|
||||
{t.name}
|
||||
</span>
|
||||
<span className={styles.toolGroup} title={t.group}>
|
||||
{t.group}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,9 @@ export function LiveTemplatesPage() {
|
||||
<span className={clsx(styles.itemLabel, isModifiedBuiltin(t.id) && styles.modified)}>
|
||||
{t.abbrev}
|
||||
</span>
|
||||
<span className={styles.itemDetail}>{t.description}</span>
|
||||
<span className={styles.itemDetail} title={t.description}>
|
||||
{t.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,9 @@ export function PostfixPage() {
|
||||
aria-label={tx`Enable .${t.key}`}
|
||||
/>
|
||||
<span className={styles.mono}>.{t.key}</span>
|
||||
<span className={styles.itemDetail}>{t.description}</span>
|
||||
<span className={styles.itemDetail} title={t.description}>
|
||||
{t.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
.split {
|
||||
flex: 1;
|
||||
min-width: 520px;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -80,6 +81,8 @@
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
.itemDetail {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-subtle);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
min-width: 600px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-small);
|
||||
@@ -169,6 +170,7 @@
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { fuzzyMatch } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { openRemoteProject } from "@/workbench/projectWindow";
|
||||
|
||||
interface RecentState {
|
||||
@@ -115,6 +116,14 @@ export async function removeRecent(path: string) {
|
||||
notifications().notify({ level: "error", title: "Cannot remove recent project", body: r.error.message });
|
||||
}
|
||||
|
||||
/** An open failure's kind and message, whether it is an `IpcError` or a raw rejected invoke. */
|
||||
function failure(e: unknown): { kind?: string; message: string } {
|
||||
if (e instanceof IpcError) return { kind: e.kind, message: e.message };
|
||||
if (e && typeof e === "object" && "message" in e)
|
||||
return { kind: (e as { kind?: string }).kind, message: String((e as { message: unknown }).message) };
|
||||
return { message: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
|
||||
/** Opens a project from the recent list; offers to remove entries whose folder is gone. */
|
||||
export async function openRecent(project: Pick<RecentProject, "path" | "name">): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
@@ -140,12 +149,21 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return false;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message);
|
||||
const { kind, message } = failure(e);
|
||||
if (kind === "notFound" || (!kind && /not found|no such/i.test(message))) {
|
||||
// A balloon can be switched off for this group and the welcome screen has no log to open.
|
||||
const remove = await confirm({
|
||||
title: tx`Project folder not found: ${project.name}`,
|
||||
message: project.path,
|
||||
ok: "Remove from Recent Projects",
|
||||
});
|
||||
if (remove) await removeRecent(project.path);
|
||||
return false;
|
||||
}
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: missing ? `Project folder not found: ${project.name}` : `Cannot open ${project.name}`,
|
||||
body: missing ? project.path : message,
|
||||
title: `Cannot open ${project.name}`,
|
||||
body: message,
|
||||
key: `welcome.open:${project.path}`,
|
||||
actions: [{ label: "Remove from Recent Projects", run: () => void removeRecent(project.path) }],
|
||||
});
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"window.caption.minimize": "Minimize",
|
||||
"window.caption.maximize": "Maximize",
|
||||
"window.caption.restore": "Restore Down",
|
||||
"window.caption.exitFullScreen": "Exit Full Screen",
|
||||
"window.caption.close": "Close",
|
||||
"menu.help.keymapReference": "Keymap Reference",
|
||||
"menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"window.caption.minimize": "最小化",
|
||||
"window.caption.maximize": "最大化",
|
||||
"window.caption.restore": "向下还原",
|
||||
"window.caption.exitFullScreen": "退出全屏",
|
||||
"window.caption.close": "关闭",
|
||||
"menu.help.keymapReference": "快捷键参考",
|
||||
"menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}",
|
||||
|
||||
@@ -436,18 +436,6 @@ html {
|
||||
--fg-caption-close-hover: #ffffff;
|
||||
}
|
||||
|
||||
/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */
|
||||
:root,
|
||||
:root[data-theme="light"],
|
||||
:root[data-theme="dark-hc"] {
|
||||
--mac-window-close: #ff5f57;
|
||||
--mac-window-minimize: #febc2e;
|
||||
--mac-window-zoom: #28c840;
|
||||
--mac-window-glyph: rgb(0 0 0 / 55%);
|
||||
--mac-window-edge: rgb(0 0 0 / 12%);
|
||||
--mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* Islands Light. Every colour token of :root is redefined here. Values are
|
||||
* PyCharm's own; its secondary greys, comment / metadata colours, line numbers
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.button {
|
||||
--button-radius: var(--radius-control, 4px);
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
+4
-2
@@ -96,9 +96,11 @@ function PopupInner({
|
||||
shift({ padding: 6 }),
|
||||
sizeMw({
|
||||
padding: 6,
|
||||
apply({ rects, elements, availableHeight }) {
|
||||
apply({ rects, elements, availableHeight, availableWidth }) {
|
||||
elements.floating.style.maxHeight = `${Math.max(120, availableHeight)}px`;
|
||||
if (matchWidth) elements.floating.style.minWidth = `${rects.reference.width}px`;
|
||||
const width = Math.max(0, availableWidth);
|
||||
elements.floating.style.maxWidth = `${width}px`;
|
||||
if (matchWidth) elements.floating.style.minWidth = `${Math.min(rects.reference.width, width)}px`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -91,7 +91,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.optionLabel {
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
+3
-1
@@ -268,7 +268,9 @@ export function Select<V extends string = string>({
|
||||
>
|
||||
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
|
||||
{o.icon && <Icon name={o.icon} size={14} />}
|
||||
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{given(o.label)}</span>
|
||||
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)} title={given(o.label)}>
|
||||
{given(o.label)}
|
||||
</span>
|
||||
{o.detail && (
|
||||
<span className={styles.detail} title={o.detail}>
|
||||
{given(o.detail)}
|
||||
|
||||
@@ -252,9 +252,6 @@ const ICONS = {
|
||||
"window-maximize": "openidea/general/windowMaximize",
|
||||
"window-restore": "openidea/general/windowRestore",
|
||||
"window-close": "openidea/general/windowClose",
|
||||
"mac-window-close": "openidea/general/macWindowClose",
|
||||
"mac-window-minimize": "openidea/general/macWindowMinimize",
|
||||
"mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen",
|
||||
hide: "general/hide",
|
||||
pin: "general/pin",
|
||||
eye: "general/show",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 299 B |
@@ -1,5 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (exit full screen), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75 2.5V7.25H13.5L8.75 2.5Z" fill="#6C707E"/>
|
||||
<path d="M7.25 13.5V8.75H2.5L7.25 13.5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 319 B |
@@ -1,4 +0,0 @@
|
||||
<!-- OpenIDEA: macOS window button glyph (minimize), drawn in the full-screen title bar. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 8H12.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 277 B |
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* macOS window buttons in the title bar's slot while the window fills the screen. Native full
|
||||
* screen moves the system's buttons into the bar revealed with the menu bar, and a project tab
|
||||
* shown in its group's full-screen Space has none, so the slot kept for them would be empty.
|
||||
* Close closes this project's tab, the green button leaves full screen, minimize is disabled.
|
||||
* Like the system's, they take no keyboard focus.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { Icon } from "@/ui";
|
||||
import styles from "./TitleBar.module.css";
|
||||
|
||||
type AppWindow = import("@tauri-apps/api/window").Window;
|
||||
|
||||
function useFullscreenWindow(): { fullscreen: boolean; active: boolean } {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [active, setActive] = useState(() => document.hasFocus());
|
||||
useEffect(() => {
|
||||
// The plain-browser shell and tests have no Tauri window to ask.
|
||||
if (!isTauri()) return;
|
||||
let disposed = false;
|
||||
const offs: (() => void)[] = [];
|
||||
const sync = () =>
|
||||
void commands
|
||||
.windowIsFullscreen()
|
||||
.then((on) => !disposed && setFullscreen(on))
|
||||
.catch(() => {});
|
||||
sync();
|
||||
offs.push(onIdeEvent("windowFullscreenChanged", sync));
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
const w: AppWindow = getCurrentWindow();
|
||||
const listeners = await Promise.all([
|
||||
w.onResized(sync),
|
||||
w.onFocusChanged(({ payload }) => {
|
||||
setActive(payload);
|
||||
sync();
|
||||
}),
|
||||
]);
|
||||
if (disposed) listeners.forEach((off) => off());
|
||||
else offs.push(...listeners);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
offs.forEach((off) => off());
|
||||
};
|
||||
}, []);
|
||||
return { fullscreen, active };
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
if (!isTauri()) return;
|
||||
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close());
|
||||
}
|
||||
|
||||
export function MacWindowButtons() {
|
||||
const { fullscreen, active } = useFullscreenWindow();
|
||||
if (!fullscreen) return null;
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.macButtons, !active && styles.macButtonsInactive)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macClose)}
|
||||
aria-label={t("window.caption.close")}
|
||||
onClick={closeWindow}
|
||||
>
|
||||
<Icon name="mac-window-close" size={8} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macMinimize)}
|
||||
aria-label={t("window.caption.minimize")}
|
||||
disabled
|
||||
>
|
||||
<Icon name="mac-window-minimize" size={8} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.macButton, styles.macZoom)}
|
||||
aria-label={t("window.caption.exitFullScreen")}
|
||||
onClick={() => void commands.windowToggleFullscreen()}
|
||||
>
|
||||
<Icon name="mac-window-exit-full-screen" size={8} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
/* Keep the gear on the icon-only right stripe's centre line, including compact mode. */
|
||||
padding-right: calc(var(--stripe-outer-padding) + (var(--stripe-icon-button) - var(--titlebar-tool-button)) / 2);
|
||||
}
|
||||
/* Native full screen moves the window buttons out of the bar. */
|
||||
.overlayFullscreen {
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Welcome window: plain title on the frame colour with a hairline, like IntelliJ's welcome frame. */
|
||||
.minimal {
|
||||
background: var(--bg-elev);
|
||||
@@ -195,52 +199,3 @@
|
||||
background: var(--caption-close-pressed);
|
||||
color: var(--fg-caption-close-hover);
|
||||
}
|
||||
|
||||
/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */
|
||||
.macButtons {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: var(--h-titlebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 12px;
|
||||
zoom: calc(1 / var(--titlebar-zoom, 1));
|
||||
z-index: 1;
|
||||
}
|
||||
.macButton {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mac-window-glyph);
|
||||
box-shadow: inset 0 0 0 0.5px var(--mac-window-edge);
|
||||
}
|
||||
.macButton > * {
|
||||
opacity: 0;
|
||||
}
|
||||
.macButtons:hover .macButton:not(:disabled) > * {
|
||||
opacity: 1;
|
||||
}
|
||||
.macClose {
|
||||
background: var(--mac-window-close);
|
||||
}
|
||||
.macMinimize {
|
||||
background: var(--mac-window-minimize);
|
||||
}
|
||||
.macZoom {
|
||||
background: var(--mac-window-zoom);
|
||||
}
|
||||
.macButton:active:not(:disabled) {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */
|
||||
.macButton:disabled,
|
||||
.macButtonsInactive:not(:hover) .macButton {
|
||||
background: var(--mac-window-inactive);
|
||||
}
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user